diff --git a/.env.example b/.env.example index 8bb27a4b..29a1b15d 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -50,6 +53,7 @@ ECONOMIC_API_APP_SECRET_TOKEN= # Edge broker defaults for shell relay and gateway dispatch. 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-dev # Redis credentials diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..2aef528d --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/ci.env b/.github/ci.env new file mode 100644 index 00000000..1f679569 --- /dev/null +++ b/.github/ci.env @@ -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 diff --git a/.github/ci.env.staging b/.github/ci.env.staging new file mode 100644 index 00000000..1f679569 --- /dev/null +++ b/.github/ci.env.staging @@ -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 diff --git a/.github/docker-compose.ci.yml b/.github/docker-compose.ci.yml index c23789aa..76726460 100644 --- a/.github/docker-compose.ci.yml +++ b/.github/docker-compose.ci.yml @@ -1,5 +1,16 @@ services: + traefik: + container_name: "${COMPOSE_PROJECT_NAME:-api}-traefik" + + redis: + container_name: "${COMPOSE_PROJECT_NAME:-api}-redis" + + mysql-debug: + container_name: "${COMPOSE_PROJECT_NAME:-api}-mysql-debug" + ports: !reset [] + edge-broker: + container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker" labels: - "traefik.http.routers.edge-broker-local-ci.rule=PathPrefix(`/api/edge-broker`)" - "traefik.http.routers.edge-broker-local-ci.entrypoints=web" @@ -8,6 +19,8 @@ services: - "traefik.http.routers.edge-broker-local-ci.service=edge-broker" 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" @@ -18,24 +31,50 @@ services: - ci_php_app:/var/www/html 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 php2: + container_name: "${COMPOSE_PROJECT_NAME:-api}-php2" volumes: - ci_php_app:/var/www/html php3: + container_name: "${COMPOSE_PROJECT_NAME:-api}-php3" volumes: - ci_php_app:/var/www/html php4: + container_name: "${COMPOSE_PROJECT_NAME:-api}-php4" volumes: - ci_php_app:/var/www/html php5: + container_name: "${COMPOSE_PROJECT_NAME:-api}-php5" volumes: - ci_php_app:/var/www/html diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index 0ff8a24e..1ee1d907 100644 --- a/.github/workflows/code_quality.yml +++ b/.github/workflows/code_quality.yml @@ -20,14 +20,32 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.sha }} # Use PR head when available, otherwise the pushed SHA. fetch-depth: 0 # a full history is required for pull request analysis + - name: Mark repository as safe for Git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Prepare Qodana cache directories run: | mkdir -p "${RUNNER_TEMP}/qodana/caches" mkdir -p "${RUNNER_TEMP}/qodana/results" + - name: Detect Qodana Cloud token + id: qodana-token + env: + QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} + run: | + if [ -n "${QODANA_TOKEN:-}" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + fi + - name: 'Qodana Scan' - uses: JetBrains/qodana-action@v2025.3 + if: ${{ steps.qodana-token.outputs.present == 'true' }} + uses: JetBrains/qodana-action@v2026.1 with: pr-mode: false env: QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} QODANA_ENDPOINT: 'https://qodana.cloud' + + - name: 'Skip Qodana Scan (missing cloud token)' + if: ${{ steps.qodana-token.outputs.present != 'true' }} + run: echo "Skipping Qodana because QODANA_TOKEN is not configured for this repository." diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bdfd285d..07e0bbe5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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: | @@ -142,6 +128,7 @@ jobs: runs-on: [self-hosted, Linux, X64, default] env: COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml + COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }} TRAEFIK_WEB_PORT: "18080" TRAEFIK_WEBSECURE_PORT: "18443" TRAEFIK_WEBSECURE_STAGING_PORT: "18433" @@ -152,22 +139,12 @@ 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 + printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300\n' >> .env - name: Setup Node.js uses: actions/setup-node@v4 @@ -175,13 +152,19 @@ jobs: node-version: 22 - name: Boot local stack - run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 caddy + run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy - name: Sync PHP app checkout - 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: > @@ -199,7 +182,18 @@ 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} + CONFIG_DB_DEBUG_DATABASE=\${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug} + CONFIG_DB_DEBUG_PORT=3306 API_TEST_REQUEST_TIMEOUT=180 EDGE_GATEWAY_VIEW_CACHE_TTL=0 EDGE_BROKER_URL= @@ -215,6 +209,16 @@ 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} + CONFIG_DB_DEBUG_DATABASE=\${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug} + CONFIG_DB_DEBUG_PORT=3306 EDGE_BROKER_URL= vendor/bin/pest tests/Integration/EdgeGateway --colors=always" @@ -229,9 +233,15 @@ jobs: --name "$runner" \ --network "${compose_project}_default" \ -e COMPOSE_FILE="$COMPOSE_FILE" \ + -e COMPOSE_PROJECT_NAME="$compose_project" \ + -e TRAEFIK_WEB_PORT="${TRAEFIK_WEB_PORT:-18080}" \ + -e TRAEFIK_WEBSECURE_PORT="${TRAEFIK_WEBSECURE_PORT:-18443}" \ + -e TRAEFIK_WEBSECURE_STAGING_PORT="${TRAEFIK_WEBSECURE_STAGING_PORT:-18433}" \ + -e TRAEFIK_METRICS_PORT="${TRAEFIK_METRICS_PORT:-19100}" \ -e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \ -e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \ -e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \ + -e EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP="true" \ -v /var/run/docker.sock:/var/run/docker.sock \ -w /workspace \ node:22-alpine \ @@ -246,86 +256,31 @@ 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 \ + --connect-timeout 10 \ + --retry 5 \ + --retry-all-errors \ + --retry-delay 15 \ + --retry-max-time 300 \ + -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 }} diff --git a/.gitignore b/.gitignore index 5b026400..13b23765 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ /services/caddy/logs* /.tmp/ /.env.staging + +/services/nginx/app/storage/replication-bootstrap.json diff --git a/Dockerfile b/Dockerfile index fb3e0914..66cde638 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] \ No newline at end of file +CMD ["php-fpm"] diff --git a/Dockerfile.coolify-api b/Dockerfile.coolify-api new file mode 100644 index 00000000..e768acf4 --- /dev/null +++ b/Dockerfile.coolify-api @@ -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"] diff --git a/config.example.php b/config.example.php index 1f2dac15..f7f6b01b 100644 --- a/config.example.php +++ b/config.example.php @@ -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) diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 3bb8faab..e5ed7b85 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -52,6 +52,7 @@ services: dockerfile: services/edge-broker/Dockerfile container_name: edge-broker environment: + EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-manager} EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy} EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} labels: diff --git a/docker-compose.prod.standalone.yml b/docker-compose.prod.standalone.yml index d647ab32..33a89c6f 100644 --- a/docker-compose.prod.standalone.yml +++ b/docker-compose.prod.standalone.yml @@ -77,6 +77,7 @@ services: dockerfile: services/edge-broker/Dockerfile container_name: edge-broker environment: + EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-manager} EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy} EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} labels: @@ -95,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" @@ -152,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" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index aafc0bd2..e7d257d4 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -27,5 +27,5 @@ services: ## docker compose up -d traefik caddy php1 php2 php3 php4 php5 db redis ## ## Notes: -## - Traefik uses Let’s 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 Let’s 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. diff --git a/docker-compose.yml b/docker-compose.yml index 2fc457fb..1c91555b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,41 @@ services: - "traefik.http.routers.traefik-local.entrypoints=web" - "traefik.http.routers.traefik-local.service=api@internal" - "traefik.http.routers.traefik-local.middlewares=dashboard-allow-local@file,dashboard-auth@file" + # Broker API (handled in edge-broker service) + - "traefik.http.routers.edge-broker.rule=Host(`api.truckwash.dk`) && PathPrefix(`/edge-broker`)" + - "traefik.http.routers.edge-broker.entrypoints=websecure" + - "traefik.http.routers.edge-broker.tls=true" + - "traefik.http.routers.edge-broker.tls.certresolver=le" + - "traefik.http.routers.edge-broker.middlewares=secure-headers@file,edge-broker-strip" + - "traefik.http.routers.edge-broker.priority=200" + - "traefik.http.routers.edge-broker.service=edge-broker" + - "traefik.http.routers.edge-broker-io.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)" + - "traefik.http.routers.edge-broker-io.entrypoints=websecure" + - "traefik.http.routers.edge-broker-io.tls=true" + - "traefik.http.routers.edge-broker-io.tls.certresolver=le_io" + - "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" + - "traefik.http.routers.edge-broker-staging.tls.certresolver=le_io" + - "traefik.http.routers.edge-broker-staging.middlewares=secure-headers@file,edge-broker-strip" + - "traefik.http.routers.edge-broker-staging.priority=200" + - "traefik.http.routers.edge-broker-staging.service=edge-broker" + - "traefik.http.routers.edge-broker-local.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)" + - "traefik.http.routers.edge-broker-local.entrypoints=web" + - "traefik.http.routers.edge-broker-local.middlewares=secure-headers@file,edge-broker-strip-local" + - "traefik.http.routers.edge-broker-local.priority=200" + - "traefik.http.routers.edge-broker-local.service=edge-broker" + - "traefik.http.routers.edge-broker-local-secure.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)" redis: image: redis:7 @@ -86,6 +121,7 @@ services: dockerfile: services/edge-broker/Dockerfile container_name: edge-broker environment: + EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-manager} EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy} EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} labels: @@ -104,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" @@ -163,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" @@ -202,7 +253,7 @@ services: - php-staging command: ["caddy", "run", "--config", "/etc/caddy/Caddyfile-staging", "--adapter", "caddyfile"] volumes: - - ./services/nginx/app:/var/www/html + - ./services/nginx/staging:/var/www/html - ./services/caddy:/etc/caddy:ro - ./services/caddy/logs-staging:/var/log/caddy labels: @@ -358,7 +409,7 @@ services: EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} volumes: - - ./services/nginx/app:/var/www/html + - ./services/nginx/staging:/var/www/html - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro - ./services/php/logs-staging:/var/log/php diff --git a/nginx-example.conf b/nginx-example.conf index 6707e45e..e0ae4061 100644 --- a/nginx-example.conf +++ b/nginx-example.conf @@ -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)$ { } } -} \ No newline at end of file +} diff --git a/nginx.conf b/nginx.conf index 1904efbc..ce4bf628 100644 --- a/nginx.conf +++ b/nginx.conf @@ -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. } } -} \ No newline at end of file +} diff --git a/openapi.yaml b/openapi.yaml index 5c87c08f..9db79e35 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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: @@ -2204,6 +2362,8 @@ paths: transaction_draft_customer_number: type: integer nullable: true + default_distribution_department_id: + type: integer additionalProperties: false additionalProperties: true '400': @@ -3395,7 +3555,7 @@ paths: tags: - Departments summary: List departments - description: Retrieve a list of all visible departments + description: Retrieve visible, active departments by default. Superuser department access may filter archived departments with `filters=archived:1`. operationId: listDepartments parameters: - name: id @@ -3406,6 +3566,11 @@ paths: - $ref: '#/components/parameters/PageParam' - $ref: '#/components/parameters/PerPageParam' - $ref: '#/components/parameters/SearchParam' + - name: filters + in: query + schema: + type: string + description: Comma-separated field filters. `archived:1` is only honored for users with superuser department access. responses: '200': description: Departments retrieved successfully @@ -3857,16 +4022,25 @@ paths: schema: type: integer minimum: 1 + - name: dynamic_image_id + in: query + required: false + description: Dynamic image ID to preview instead of the lane's saved image + schema: + type: integer + minimum: 1 - 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 @@ -5674,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: @@ -6153,6 +6327,34 @@ paths: '503': { $ref: '#/components/responses/ServiceUnavailable' } '500': { $ref: '#/components/responses/InternalServerError' } + /collected-invoices/economic/queue/monitor: + get: + tags: + - Invoices + summary: Monitor current-user visible collected-invoice e-conomic transfer queue jobs + operationId: monitorCollectedInvoiceEconomicQueueJobs + parameters: + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + responses: + '200': + description: Queue monitor state retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueMonitorResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + /collected-invoices/economic/queue/status: get: tags: @@ -6212,6 +6414,57 @@ paths: '503': { $ref: '#/components/responses/ServiceUnavailable' } '500': { $ref: '#/components/responses/InternalServerError' } + /collected-invoices/economic/queue/dismiss: + post: + tags: + - Invoices + summary: Clear one completed or failed collected-invoice queue job for the current user + operationId: dismissCollectedInvoiceEconomicQueueJob + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [job_id] + properties: + job_id: + type: integer + minimum: 1 + responses: + '200': + description: Queue job cleared + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueDismissResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue/dismiss-terminal: + post: + tags: + - Invoices + summary: Clear all visible completed or failed collected-invoice queue jobs for the current user + operationId: dismissCollectedInvoiceEconomicTerminalQueueJobs + responses: + '200': + description: Terminal queue jobs cleared + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueDismissTerminalResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + /collected-invoices/economic/queue/run: post: tags: @@ -7452,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: @@ -7803,6 +8064,93 @@ paths: '404': $ref: '#/components/responses/NotFound' + /relay/machine/on/post: + get: + tags: + - Plate Scans + summary: Record Shelly machine ON signal webhook + description: Accepts Shelly Cloud webhook/query parameters for input.toggle_on or switch.on and records the physical machine start signal for self-serve billing. + operationId: recordShellyMachineOnSignal + parameters: + - name: token + in: query + required: false + schema: {type: string} + - name: lane_id + in: query + required: false + schema: + type: integer + - name: relay_id + in: query + required: false + schema: + type: string + - name: event + in: query + required: false + schema: + type: string + enum: [input.toggle_on, switch.on] + - name: reg + in: query + required: false + schema: + type: string + responses: + '201': + description: Machine ON signal recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '202': + description: Shelly signal was recognized but ignored + '400': + $ref: '#/components/responses/BadRequest' + post: + tags: + - Plate Scans + summary: Record Shelly machine ON signal webhook + description: Accepts Shelly Cloud JSON webhook payloads for input.toggle_on or switch.on and records the physical machine start signal for self-serve billing. + operationId: recordShellyMachineOnSignalPost + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + token: + type: string + lane_id: + type: integer + relay_id: + type: string + event: + type: string + enum: [input.toggle_on, switch.on] + component: + type: string + example: input:0 + state: + type: boolean + output: + type: boolean + reg: + type: string + responses: + '201': + description: Machine ON signal recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '202': + description: Shelly signal was recognized but ignored + '400': + $ref: '#/components/responses/BadRequest' + # Module - e-conomic Endpoints /economic/customers/import: post: @@ -8495,6 +8843,120 @@ paths: '403': $ref: '#/components/responses/Forbidden' + /modules/self-serve/sessions: + get: + tags: + - Modules + summary: List self-serve wash sessions + description: Retrieve paginated self-serve wash sessions with search, filters, ordering, and active/open-only support. + operationId: listSelfServeSessions + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/LimitParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/FiltersParam' + - name: order + in: query + required: false + schema: + type: string + example: id:DESC + - name: open_only + in: query + required: false + schema: + type: boolean + responses: + '200': + description: Self-serve wash sessions retrieved successfully + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: '#/components/schemas/SelfserveWashSession' + - type: object + properties: + elapsed_minutes: + type: integer + open: + type: boolean + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/sessions/{id}: + get: + tags: + - Modules + summary: Get self-serve wash session detail + operationId: getSelfServeSessionDetail + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Self-serve wash session detail retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveWashSummary' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /modules/self-serve/lane/force/stop: + post: + tags: + - Modules + summary: Force stop a self-serve wash session + description: | + Clears the current self-serve wash session and lane runtime with RESET behavior only. + This administrative action does not signal relays or gates. When billing is requested, + only elapsed-minute billing is attempted before runtime is cleared. + operationId: forceStopSelfServeLane + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - bill + properties: + lane_id: + type: integer + session_id: + type: integer + nullable: true + bill: + type: boolean + reason: + type: string + nullable: true + responses: + '200': + description: Self-serve wash force stopped successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveForceStopResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + /modules/self-serve/lane/command: post: tags: @@ -8503,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 @@ -8553,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 @@ -8996,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 @@ -10753,12 +11224,23 @@ paths: name: {type: string} description: {type: string} cvr: {type: integer} + address: {type: string, nullable: true} + phone_country_code: {type: integer, nullable: true} + phone: {type: integer, nullable: true} + email: {type: string, nullable: true} + website: {type: string, nullable: true} + banner: {type: string, nullable: true} + logo: {type: string, nullable: true} + favicon: {type: string, nullable: true} + signature: {type: string, nullable: true} responses: '200': description: Branding option added successfully content: application/json: schema: {} + '400': + $ref: '#/components/responses/BadRequest' '403': $ref: '#/components/responses/Forbidden' put: @@ -10776,17 +11258,30 @@ paths: required: [id] properties: id: {type: integer} - name: {type: string} - description: {type: string} - cvr: {type: integer} + name: {type: string, nullable: true} + description: {type: string, nullable: true} + cvr: {type: integer, nullable: true} + address: {type: string, nullable: true} + phone_country_code: {type: integer, nullable: true} + phone: {type: integer, nullable: true} + email: {type: string, nullable: true} + website: {type: string, nullable: true} + banner: {type: string, nullable: true} + logo: {type: string, nullable: true} + favicon: {type: string, nullable: true} + signature: {type: string, nullable: true} responses: '200': description: Branding option updated successfully content: application/json: schema: {} + '400': + $ref: '#/components/responses/BadRequest' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' /roles: get: @@ -10960,6 +11455,36 @@ paths: application/json: schema: {} + /superuser/department/branding: + put: + tags: + - Departments + summary: Set department branding + description: Assign an existing branding option to a department, or clear the department branding by sending a null branding_id. + operationId: setDepartmentBranding + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department_id, branding_id] + properties: + department_id: {type: integer} + branding_id: {type: integer, nullable: true} + responses: + '200': + description: Department branding updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /superuser/department/prices: get: tags: @@ -11478,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: @@ -11591,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: @@ -12190,7 +13074,7 @@ components: type: object properties: module: { type: string, enum: [economic] } - variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber, transactionDraftCustomerNumber] } + variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber, transactionDraftCustomerNumber, defaultDepartmentId] } type: { type: string, enum: [string, int] } value: oneOf: @@ -13614,6 +14498,140 @@ components: - meta - includes + EconomicTransferQueueMonitorResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + jobs: + type: array + items: + $ref: '#/components/schemas/EconomicTransferQueueJob' + counts: + type: object + properties: + queued: + type: integer + minimum: 0 + in_progress: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + completed: + type: integer + minimum: 0 + total: + type: integer + minimum: 0 + required: + - queued + - in_progress + - failed + - completed + - total + progress_percent: + type: integer + minimum: 0 + maximum: 100 + limit: + type: integer + minimum: 1 + maximum: 100 + required: + - jobs + - counts + - progress_percent + - limit + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueDismissResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + job: + $ref: '#/components/schemas/EconomicTransferQueueJob' + required: + - message + - job + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueDismissTerminalResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + dismissed_count: + type: integer + minimum: 0 + required: + - message + - dismissed_count + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + CollectedInvoiceEconomicCompareResponse: type: object description: Result of comparing a collected invoice with its E-conomic counterpart @@ -14833,6 +15851,14 @@ components: type: string status: type: string + enum: + - PENDING_QUESTIONS + - READY_FOR_MACHINE_START + - MACHINE_NOT_ALLOWED + - MACHINE_RELAY_ENABLED + - MACHINE_STARTED + - COMPLETED + - FORCE_STOPPED allowed: type: boolean machine_relay_enabled: @@ -14911,6 +15937,12 @@ components: type: integer type: type: string + enum: + - SESSION_SYNCED + - MACHINE_RELAY_ENABLED + - MACHINE_START_TRIGGERED + - SESSION_COMPLETED + - SESSION_FORCE_STOPPED payload: type: object additionalProperties: true @@ -14984,11 +16016,41 @@ components: type: array items: $ref: '#/components/schemas/SelfserveWashTaskSnapshot' + allowed_services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + machine_available: + type: boolean + all_visible_questions_answered: + type: boolean + allowed: + type: boolean events: type: array items: $ref: '#/components/schemas/SelfserveWashEvent' + SelfserveForceStopResponse: + type: object + properties: + lane_id: + type: integer + forced: + type: boolean + bill: + type: boolean + order_id: + type: integer + nullable: true + session: + allOf: + - $ref: '#/components/schemas/SelfserveWashSummary' + nullable: true + runtime_before_reset: + type: object + additionalProperties: true + DepartmentSelfserveVehicleConditionMutationResponse: type: object properties: @@ -15301,6 +16363,8 @@ components: type: integer visible: type: boolean + archived: + type: boolean dimension: type: integer branding: @@ -15371,6 +16435,8 @@ components: type: integer visible: type: boolean + archived: + type: boolean longitude: type: number format: float @@ -15391,6 +16457,8 @@ components: type: string visible: type: boolean + archived: + type: boolean longitude: type: number format: float diff --git a/scripts/.php-ci-test.lf.52582.sh b/scripts/.php-ci-test.lf.52582.sh new file mode 100644 index 00000000..63158f36 --- /dev/null +++ b/scripts/.php-ci-test.lf.52582.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env sh +set -eu + +suite="${1:-}" +case "$suite" in + unit|integration|api|legacy|all) + ;; + *) + echo "Usage: $0 " >&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" diff --git a/scripts/edge-gateway-e2e.mjs b/scripts/edge-gateway-e2e.mjs index 2a923200..94595991 100644 --- a/scripts/edge-gateway-e2e.mjs +++ b/scripts/edge-gateway-e2e.mjs @@ -10,7 +10,7 @@ import { promisify } from "node:util"; import { DEFAULT_CONFIG_FILE_NAME, DEFAULT_HOST_API_URL } from "./test-gateway.mjs"; const execFile = promisify(execFileCallback); -const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "caddy"]; +const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "php2", "php3", "php4", "php5", "caddy"]; function composeArgs(projectName, args) { return ["compose", "-p", projectName, ...args]; @@ -514,6 +514,10 @@ function shouldCopyGatewayConfig() { return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_COPY_CONFIG || "").trim()); } +function shouldSkipComposeUp() { + return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP || "").trim()); +} + function collectMessages(rows) { return Array.isArray(rows) ? rows @@ -570,7 +574,9 @@ async function main() { let runnerNetworkAttached = false; try { - await ensureComposeServices(rootDir, composeProject); + if (!shouldSkipComposeUp()) { + await ensureComposeServices(rootDir, composeProject); + } runnerNetworkAttached = await connectCurrentContainerToComposeNetwork(rootDir, composeProject); baseUrl = await waitForApiReady(baseUrl, rootDir, composeProject, runnerNetworkAttached); process.stdout.write(`Using API base URL ${baseUrl}\n`); diff --git a/scripts/php-ci-test.sh b/scripts/php-ci-test.sh new file mode 100644 index 00000000..63158f36 --- /dev/null +++ b/scripts/php-ci-test.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env sh +set -eu + +suite="${1:-}" +case "$suite" in + unit|integration|api|legacy|all) + ;; + *) + echo "Usage: $0 " >&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" diff --git a/scripts/test-gateway.mjs b/scripts/test-gateway.mjs index 313e35d6..47bb3d3f 100644 --- a/scripts/test-gateway.mjs +++ b/scripts/test-gateway.mjs @@ -25,6 +25,16 @@ function composeArgs(projectName, args) { return ["compose", "-p", projectName, ...args]; } +function usesWindowsPathSyntax(filePath) { + return /^[A-Za-z]:($|[\\/])/.test(filePath) || filePath.startsWith("\\\\") || filePath.includes("\\"); +} + +function pathForInputs(...filePaths) { + const hasWindowsPath = filePaths.some((filePath) => usesWindowsPathSyntax(String(filePath || ""))); + + return hasWindowsPath ? path.win32 : path; +} + async function resolveRootDir(scriptPath) { const cwd = process.cwd(); @@ -66,7 +76,7 @@ export function resolveComposeProjectName(rootDir, env = process.env) { return explicit; } - return path.basename(rootDir); + return pathForInputs(rootDir).basename(rootDir); } export function resolveComposeNetworkName(rootDir, env = process.env) { @@ -74,11 +84,13 @@ export function resolveComposeNetworkName(rootDir, env = process.env) { } export function resolveConfigDirectory(rootDir, explicitDir = null) { + const pathModule = pathForInputs(rootDir, explicitDir); + if (explicitDir) { - return path.resolve(rootDir, explicitDir); + return pathModule.resolve(rootDir, explicitDir); } - return path.join(rootDir, ".tmp", "test-gateway"); + return pathModule.join(rootDir, ".tmp", "test-gateway"); } export function shouldClaimGateway(existingConfig = {}, installToken = "") { diff --git a/services/coolify/api/nginx.conf b/services/coolify/api/nginx.conf new file mode 100644 index 00000000..a7bdc1a4 --- /dev/null +++ b/services/coolify/api/nginx.conf @@ -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; + } + } +} diff --git a/services/coolify/api/start.sh b/services/coolify/api/start.sh new file mode 100644 index 00000000..6a364078 --- /dev/null +++ b/services/coolify/api/start.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -e + +mkdir -p /run/nginx +php-fpm -D +exec nginx -g "daemon off;" diff --git a/services/edge-broker/server.mjs b/services/edge-broker/server.mjs index 616ed108..27386e3a 100644 --- a/services/edge-broker/server.mjs +++ b/services/edge-broker/server.mjs @@ -55,7 +55,7 @@ function resolveAuthMode(options = {}, managerUrl = "") { if (process.env.EDGE_AUTH_MODE) { return process.env.EDGE_AUTH_MODE; } - return managerUrl ? "manager" : "stub"; + return "manager"; } function parseScopes(value) { @@ -447,6 +447,35 @@ export function createBrokerServer(options = {}) { const server = http.createServer(async (req, res) => { try { const url = new URL(req.url, "http://localhost"); + if (req.method === "GET" && url.pathname === "/api/health") { + jsonResponse(res, 200, { + ok: true, + service: "edge-broker", + auth_mode: authMode, + manager_url_configured: Boolean(managerUrl), + shared_secret_configured: Boolean(sharedSecret), + agents_connected: agents.size, + }); + return; + } + + if (req.method === "POST" && url.pathname === "/api/diagnostics/shared-secret") { + if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) { + jsonResponse(res, 403, { + ok: false, + error: "Forbidden", + shared_secret_required: true, + }); + return; + } + + jsonResponse(res, 200, { + ok: true, + shared_secret_required: Boolean(sharedSecret), + }); + return; + } + if (req.method === "POST" && /^\/api\/gateways\/\d+\/commands$/.test(url.pathname)) { if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) { jsonResponse(res, 403, { error: "Forbidden" }); @@ -522,7 +551,17 @@ export function createBrokerServer(options = {}) { return; } - const gatewayInfo = await validateAgent({ gatewayId, token, headers: req.headers }); + let gatewayInfo; + try { + gatewayInfo = await validateAgent({ gatewayId, token, headers: req.headers }); + } catch (error) { + const status = Number(error?.status) === 403 ? 403 : Number(error?.status) === 401 ? 401 : 503; + rejectUpgrade(socket, status, error?.code || "agent_validation_failed", normalizeErrorMessage(error, "Gateway agent could not be validated."), { + stage: "agent_validate", + }); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { const existing = agents.get(gatewayId); if (existing && existing.readyState < 2) { diff --git a/services/edge-broker/test/broker.test.mjs b/services/edge-broker/test/broker.test.mjs index a5201c9b..7ba8a357 100644 --- a/services/edge-broker/test/broker.test.mjs +++ b/services/edge-broker/test/broker.test.mjs @@ -59,6 +59,51 @@ async function waitFor(predicate, { timeoutMs = 1000, intervalMs = 10, descripti throw new Error(`Timed out waiting for ${description}`); } +test("broker defaults to manager auth and fails closed when manager URL is missing", async () => { + const previousEnv = { + EDGE_AUTH_MODE: process.env.EDGE_AUTH_MODE, + EDGE_MANAGER_URL: process.env.EDGE_MANAGER_URL, + EDGE_PUBLIC_API_URL: process.env.EDGE_PUBLIC_API_URL, + }; + delete process.env.EDGE_AUTH_MODE; + delete process.env.EDGE_MANAGER_URL; + delete process.env.EDGE_PUBLIC_API_URL; + + let broker; + try { + broker = createBrokerServer({ sharedSecret: "secret" }); + assert.equal(broker.state.authMode, "manager"); + assert.equal(broker.state.managerUrl, ""); + + const address = await broker.listen(0); + const port = address.port; + const shellResponse = await rawUpgradeRequest(port, "/ws/browser-shell?token=session-token"); + const agentResponse = await rawUpgradeRequest(port, "/ws/agent?gatewayId=701&token=agent-token"); + + assert.doesNotMatch(shellResponse, /101 Switching Protocols/); + assert.match(shellResponse, /^HTTP\/1\.1 401 Unauthorized/m); + assert.match(shellResponse, /"error_code":"shell_session_invalid"/); + assert.match(shellResponse, /Edge manager URL is not configured/); + + assert.doesNotMatch(agentResponse, /101 Switching Protocols/); + assert.match(agentResponse, /^HTTP\/1\.1 503 Service Unavailable/m); + assert.match(agentResponse, /"error_code":"agent_validation_failed"/); + assert.match(agentResponse, /"stage":"agent_validate"/); + assert.match(agentResponse, /Edge manager URL is not configured/); + } finally { + if (broker) { + await broker.close(); + } + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +}); + test("broker dispatches commands to connected agents", async () => { const broker = createBrokerServer({ authMode: "stub", sharedSecret: "secret", commandTimeoutMs: 2000 }); const address = await broker.listen(0); @@ -99,6 +144,48 @@ test("broker dispatches commands to connected agents", async () => { await broker.close(); }); +test("broker exposes health and shared-secret diagnostics", async () => { + const broker = createBrokerServer({ authMode: "manager", sharedSecret: "secret", managerUrl: "http://manager.test" }); + const address = await broker.listen(0); + const port = address.port; + + const healthResponse = await fetch(`http://127.0.0.1:${port}/api/health`); + const healthJson = await healthResponse.json(); + + assert.equal(healthResponse.status, 200); + assert.equal(healthJson.ok, true); + assert.equal(healthJson.service, "edge-broker"); + assert.equal(healthJson.auth_mode, "manager"); + assert.equal(healthJson.manager_url_configured, true); + assert.equal(healthJson.shared_secret_configured, true); + + const invalidSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, { + method: "POST", + headers: { + "x-edge-broker-secret": "wrong-secret", + }, + }); + const invalidSecretJson = await invalidSecretResponse.json(); + + assert.equal(invalidSecretResponse.status, 403); + assert.equal(invalidSecretJson.ok, false); + assert.equal(invalidSecretJson.shared_secret_required, true); + + const validSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, { + method: "POST", + headers: { + "x-edge-broker-secret": "secret", + }, + }); + const validSecretJson = await validSecretResponse.json(); + + assert.equal(validSecretResponse.status, 200); + assert.equal(validSecretJson.ok, true); + assert.equal(validSecretJson.shared_secret_required, true); + + await broker.close(); +}); + test("broker bridges browser shell sessions through the connected agent", async () => { const closedSessions = []; const broker = createBrokerServer({ diff --git a/services/edge-broker/test/config.test.mjs b/services/edge-broker/test/config.test.mjs index f5d347fe..dfb64706 100644 --- a/services/edge-broker/test/config.test.mjs +++ b/services/edge-broker/test/config.test.mjs @@ -39,11 +39,13 @@ test("traefik does not expose a dedicated public edge broker port", () => { test("base docker compose routes edge broker traffic through traefik", () => { const serviceBlock = readComposeServiceBlock(baseComposeSource, "edge-broker"); assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/); + assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-manager\}/); assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/); assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/); assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-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/); @@ -53,6 +55,7 @@ test("base docker compose routes edge broker traffic through traefik", () => { test("example docker compose routes edge broker traffic through traefik", () => { const serviceBlock = readComposeServiceBlock(exampleComposeSource, "edge-broker"); assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/); + assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-manager\}/); assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/); assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.example\.com`\) && PathPrefix\(`\/edge-broker`\)/); assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/); @@ -62,11 +65,13 @@ test("example docker compose routes edge broker traffic through traefik", () => test("standalone production compose routes edge broker traffic through traefik", () => { const serviceBlock = readComposeServiceBlock(standaloneProdComposeSource, "edge-broker"); assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/); + assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-manager\}/); assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/); assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/); assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-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/); }); diff --git a/services/nginx/app/.phpunit.cache/test-results b/services/nginx/app/.phpunit.cache/test-results index 493b0c5d..46afcacf 100644 --- a/services/nginx/app/.phpunit.cache/test-results +++ b/services/nginx/app/.phpunit.cache/test-results @@ -1 +1 @@ -{"version":"pest_3.8.6","defects":{"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_self_serve_machine_type__eligibility__summary__and_webhook_endpoints":1,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_defines_reusable_self_serve_wash_and_machine_type_schemas":1,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_redistributes_booked_department_75_net_amounts_using_fixed_pricing_and_wash_subscription_weights":7,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_keeps_classified_booked_department_75_amounts_undistributed_when_no_monthly_basis_exists":8,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_keeps_unclassified_booked_department_75_lines_undistributed_with_warnings":8,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_documents_v2_historical_distribution_and_pricing_history_paths_in_openapi":1,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_defines_new_reusable_v2_schemas_for_normalization_comparison_versioning_and_distribution":1,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_supports_bulk_booked_invoice_line_payloads_with_top_level_department_numbers":7,"P\\Tests\\Unit\\Redis\\RedisAtomicReservationTest::__pest_evaluable_it_claims_a_slot_atomically_with_nx_and_expiration":8,"P\\Tests\\Unit\\Redis\\RedisAtomicReservationTest::__pest_evaluable_it_returns_false_when_the_slot_is_already_claimed_and_clamps_ttl_to_one_second":8,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_documents_economic_v2_invoice_paths_in_openapi":1,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_aligns_legacy_compare_schema_with_runtime_payload_by_removing_stale_required_order__ids":1,"P\\Tests\\Unit\\Search\\SystemSearchEntityTypeCoverageTest::__pest_evaluable_it_documents_every_supported_search_entity_type_in_openapi_enum":1,"P\\Tests\\Unit\\Search\\SystemSearchOpenApiSpecTest::__pest_evaluable_it_documents_system_wide_search_endpoints_in_openapi":1,"P\\Tests\\Unit\\Search\\SystemSearchOpenApiSpecTest::__pest_evaluable_it_documents_debug__intent_and_parser_metadata_schema_in_openapi":1,"P\\Tests\\Unit\\Search\\SystemSearchOpenApiSpecTest::__pest_evaluable_it_documents_e_conomic_indexed_customer_matching_and_synonym_behavior":1,"P\\Tests\\Integration\\Database\\DbConnectionTest::__pest_evaluable_it_can_connect_to_a_configured_MySQL_instance_in_integration_mode":1,"P\\Tests\\Integration\\Invoicing\\EconomicV2BackfillAndDistributionIntegrationTest::__pest_evaluable_it_runs_best_effort_backfill_repeatedly_without_introducing_duplicate_same_start_rows":1,"P\\Tests\\Integration\\Invoicing\\EconomicV2BackfillAndDistributionIntegrationTest::__pest_evaluable_it_resolves_version_aware_distribution_payload_shapes_over_a_real_date_range":1,"P\\Tests\\Integration\\Invoicing\\EconomicV2VersioningServiceIntegrationTest::__pest_evaluable_it_creates_closes_and_rotates_fixed_pricing_versions_without_overlap":1,"P\\Tests\\Integration\\Invoicing\\EconomicV2VersioningServiceIntegrationTest::__pest_evaluable_it_tracks_vehicle_and_discount_version_lifecycles_with_closure_semantics":1,"P\\Tests\\Integration\\Permissions\\LegacyPermissionRedisCacheScriptTest::__pest_evaluable_it_keeps_legacy_route_permission_redis_cache_script_green":1,"P\\Tests\\Unit\\Goals\\GoalsOpenApiSpecTest::__pest_evaluable_it_documents_advanced_goals_target_duration_fields_in_openapi":1,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineSchemaConformanceTest::__pest_evaluable_it_documents_the_date_key_in_the_openapi_department_weather_timeline_schema":1,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_builds_timeline_entries_with_mostly__clear_weather_when_forecast_payload_is_empty":8,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_wires_departments_weather_route_through_the_forecast_fallback_path":7,"P\\Tests\\Unit\\Orders\\AttachmentsListManyTest::__pest_evaluable_it_groups_attachments_by_object_id_and_preserves_empty_object_groups":8,"P\\Tests\\Unit\\Orders\\AttachmentsListManyTest::__pest_evaluable_it_keeps_listMany_payload_parity_with_list_for_one_object_id":8,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsOpenApiSpecTest::__pest_evaluable_it_documents_department_weather_target_endpoints_and_reusable_schemas_in_openapi":1,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsOpenApiSpecTest::__pest_evaluable_it_documents_unknown_weather_status_behavior_when_targets_are_missing":1,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_respects_the_1_request_per_second_Shelly_gate_for_back_to_back_requests":8,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_in_progress_self_serve_wash_start_and_machine_relay_fields":1,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_stores_config_json_with_apostrophes_without_violating_json_constraints":8,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_adds_vehicle_type_product_on_STOP_when_program_selector_is_on__then_turns_off_cleaner_machine_and_selector_relays":7,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_skips_vehicle_type_product_add_when_program_selector_is_off_and_only_disables_configured_relays":7,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_self_serve_machine_wash_included_minutes_in_config_schemas":1,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_fast_when_workspace_id_is_missing_for_gate_calls":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_fast_when_channel_id_is_missing_for_gate_calls":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_with_terminal_status_details_when_call_never_reaches_accepted_state":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_hangs_up_when_call_reaches_accepted_state":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_hangs_up_immediately_when_status_transitions_to_accepted":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_maps_legacy_timeout_option_to_documented_ringTimeout_payload_field":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_clamps_derived_ringTimeout_to_Bird_documented_max_when_gate_timeout_is_high":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_passes_documented_hangup_cause_when_provided":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_drops_unsupported_hangup_cause_values_from_request_payload":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_fast_when_workspace_id_is_missing_for_gate_flash_calls":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_creates_gate_flash_call_with_documented_ringTimeout_payload":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_polls_flash_call_and_succeeds_once_accepted":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_flash_gate_flow_when_terminal_failure_status_is_returned":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_does_not_fallback_to_regular_gate_call_when_flash_succeeds":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_falls_back_to_regular_gate_call_when_flash_fails":8,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_property_gate_lane_commands_and_sanitized_gate_failure_responses":1,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_returns_raw_payload_for_malformed_JSON_responses_and_null_for_empty_responses":8,"P\\Tests\\Unit\\Bird\\BirdRouteWiringTest::__pest_evaluable_it_keeps_Bird_number_and_webhook_routes_intact":7,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_handles_malformed_and_empty_response_bodies_without_throwing_transport_errors":8,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_aggregates_multi_department_slot_statuses_using_worst_severity_and_unknown_fallback_rules":7,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_adds_vehicle_type_product_on_STOP_when_program_selector_relay_is_online__then_turns_off_cleaner_and_machine_relays":7,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_bills_primary_product_when_selector_relay_is_online_even_if_relay_output_is_off":7,"P\\Tests\\Unit\\Bird\\BirdOpenApiSpecTest::__pest_evaluable_it_documents_all_Bird_voice_call_parity_endpoints_including_gather_recordings_insights_and_log":1,"P\\Tests\\Unit\\Bird\\BirdOpenApiSpecTest::__pest_evaluable_it_documents_flash_hangup_endpoint_and_marks_end_alias_as_deprecated":1,"P\\Tests\\Unit\\Bird\\BirdOpenApiSpecTest::__pest_evaluable_it_defines_request_and_response_schemas_for_Bird_call_command_recording_insight_log_and_flash_payloads":1,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueRouteRegistrationTest::__pest_evaluable_it_registers_collected_invoice_queue_endpoints_in_orderInvoicesRoute":7,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_uses_a_consistent_unavailable_service_contract_for_missing_queue_dependencies":7,"P\\Tests\\Unit\\Invoicing\\EconomicTransferOrderItemSkipTest::__pest_evaluable_it_wires_zero_cost_and_zero_quantity_skip_guard_into_transfer_line_builder":7,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_processes_queued_transfer_jobs_to_completion":1,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_deduplicates_active_jobs_per_transfer_target":1,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_rejects_invalid_payloads_without_inserting_queue_rows":1,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_supports_fail_retry_and_reprocess_lifecycle_transitions":1,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_supports_synchronous_fallback_branches_before_queue_enqueue_on_economic_invoice_export_routes":7,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_keeps_queue_status_endpoints_guarded_while_collected_invoice_export_routes_support_synchronous_fallback":7,"P\\Tests\\Unit\\Invoicing\\UserCollectedInvoiceUpdateRouteValidationTest::__pest_evaluable_it_supports_independent_po__number_and_closed__at_updates_for_PUT__collected_invoices_in_user_route":7,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueRouteHardeningTest::__pest_evaluable_it_returns_pagination_metadata_and_strict_status_handling_for_collected_invoice_queue_list":7,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueRouteHardeningTest::__pest_evaluable_it_enforces_retry_constraints_for_collected_invoice_queue_jobs_before_retry_execution":7,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_collected_queue_list_metadata_and_strict_status_filter_enum_in_openapi":7,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_builds_a_completed_collected_invoice_queue_summary_from_payload_and_result":8,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_prefers_queue_error_messages_for_failed_jobs_and_keeps_null_safe_outcome_fields":8,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_uses_deterministic_status_message_fallback_when_no_explicit_message_exists":8,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_handles_object_payload_result_values_and_malformed_fields_without_throwing":8,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueWorkerTickRouteTest::__pest_evaluable_it_ticks_collected_invoice_transfer_queue_from_queue_list_and_status_routes":7,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueWorkerTickRouteTest::__pest_evaluable_it_ticks_order_transfer_queue_from_draft_and_invoice_status_routes":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_builds_the_installer_around_the_PHP_agent_artifacts_and_management_polling_config":8,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_exposes_update_payload__credential_rotation__and_operation_endpoints_without_shell_transport_wiring":8,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_relay_fallback_and_transport_health_details_without_shell_or_update_runtime_state":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_resolves_edge_agent_artifacts_from_the_source_tree_layout":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_prioritizes_mounted_and_baked_in_artifact_directories_before_repo_fallbacks":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_prioritizes_router_resources_before_mounted_and_baked_in_artifact_directories":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_lists_orders_for_an_admin_scoped_user_and_limits_the_results_to_the_permitted_departments":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_lists_only_the_targeted_customer_orders_for_subuser_sessions":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_returns_the_current_auth_and_permission_failures_when_order_listing_is_not_allowed":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_creates_orders_through_the_real_endpoint":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_order_creation_requests":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_updates_orders_through_the_primary_endpoint":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_reassigns_invoice_collections_when_changing_an_order_across_the_draft_customer_boundary":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_supports_legacy_field_value_metadata_updates_through_the_primary_endpoint":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_updates_through_the_primary_order_endpoint":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_updates_orders_through_the_legacy_alias_endpoint":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_supports_legacy_field_value_metadata_updates_through_the_alias_endpoint":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_updates_through_the_legacy_alias_endpoint":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_unsupported_legacy_field_value_updates_on_both_update_endpoints":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_deletes_orders_through_the_real_endpoint":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_order_delete_requests":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_regenerates_attached_wash_certificates_when_certificate_metadata_changes_through_the_primary_endpoint":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_regenerates_attached_wash_certificates_for_legacy_field_value_updates_through_the_alias_endpoint":7,"P\\Tests\\Api\\ApiCoverageManifestTest::__pest_evaluable_it_keeps_every_selected_API_operation_covered_by_happy_path_and_failure_tests":1,"P\\Tests\\Api\\ApiCoverageManifestTest::__pest_evaluable_it_keeps_the_OpenAPI_manifest_entries_aligned_with_the_API_spec":1,"P\\Tests\\Api\\ApiFixturesCleanupTest::__pest_evaluable_it_removes_generated_customer_traces_during_fixture_cleanup":1,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_logs_in_with_valid_customer_credentials":7,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_rejects_invalid_login_payloads_and_credentials":7,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_returns_the_cached_auth_session_payload_for_a_valid_token":7,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_includes_economic_runtime_config_for_uncached_auth_sessions":7,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_rejects_invalid_auth_session_tokens":1,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_logs_out_and_invalidates_the_token_for_future_session_calls":7,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_logs_out_and_invalidates_cached_subuser_sessions":7,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_rejects_invalid_logout_tokens":1,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_returns_the_raw_202_gather_webhook_contract_over_HTTP":1,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_still_prompts_for_department_selection_when_only_one_department_is_eligible":7,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_accepts_the_legacy_initial_webhook_body_with_top_level_call_identifiers":7,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_returns_native_flow_gather_data_after_a_top_level_department_selection_when_distinct_gate_choices_exist":7,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_opens_the_gate_immediately_after_a_top_level_department_selection_when_entrance_and_exit_share_the_same_gate":7,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_uses_a_multi_digit_gather_contract_when_10_departments_are_eligible":7,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_returns_a_raw_400_transport_error_for_malformed_webhook_payloads":1,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_lists_only_visible_departments_and_can_return_a_single_department_with_the_slack_webhook":7,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_department_listing_when_the_permission_is_missing":7,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_creates_departments_through_the_real_endpoint":7,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_invalid_department_create_requests":7,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_updates_departments_through_the_real_endpoint":7,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_invalid_department_update_requests":7,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_lists_department_categories_for_a_department":7,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_invalid_department_category_requests":7,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_lists_the_draft_customer_config_entry_in_economic_config_responses":7,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_round_trips_the_draft_customer_config_value_through_economic_config_updates":7,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_rejects_order_draft_exports_for_the_configured_draft_customer":7,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_rejects_booked_invoice_exports_for_the_configured_draft_customer":7,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_rejects_collected_invoice_exports_for_the_configured_draft_customer":7,"P\\Tests\\Api\\PingApiTest::__pest_evaluable_it_returns_the_ping_contract":1,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_returns_a_setup_required_error_when_department_terminal_readers_are_requested_without_terminal_setup":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_sends_a_Stripe_invoice_by_email_and_persists_the_hosted_invoice_association":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_returns_a_conflict_when_a_Stripe_hosted_invoice_is_already_active_for_the_order":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_allows_sending_a_new_Stripe_hosted_invoice_when_the_existing_association_is_already_terminal":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_voids_an_unpaid_Stripe_hosted_invoice_and_clears_the_local_order_association":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_refuses_to_cancel_a_paid_Stripe_hosted_invoice":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_returns_a_setup_required_error_when_creating_a_payment_intent_for_a_department_without_terminal_setup":8,"P\\Tests\\Api\\VehiclesApiTest::__pest_evaluable_it_returns_the_newest_vehicle_last__order__id_that_still_has_order_items":7,"P\\Tests\\Api\\VehiclesApiTest::__pest_evaluable_it_returns_a_null_vehicle_last__order__id_when_no_order_with_items_exists":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayViewCacheTest::__pest_evaluable_it_syncs_gateway_snapshots_into_cached_list_payloads_and_refreshes_fleet_usage":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayModuleRouteWiringTest::__pest_evaluable_it_keeps_only_the_module_facade_in_the_global_classes_directory_and_conditionally_loads_module_routes":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_install_script_urls_with_the_compose_edge_gateway_artifacts_and_forwarded_https_scheme":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_builds_the_installer_around_the_compose_stack_artifacts_and_management_polling_config":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_exposes_update_payload__credential_rotation__cancel_endpoints__and_operation_endpoints_without_shell_transport_wiring":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayRouteWiringTest::__pest_evaluable_it_registers_the_v2_operator_facing_edge_gateway_routes":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewaySchemaBootstrapTest::__pest_evaluable_it_defines_the_v2_edge_gateway_schema_bootstrap_tables":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewaySchemaBootstrapTest::__pest_evaluable_it_stores_operation_metadata_and_event_timelines_for_management_workflows":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_prefers_EDGE__PUBLIC__API__URL_when_explicitly_configured":7,"P\\Tests\\Api\\PlateScannersApiTest::__pest_evaluable_it_returns_the_updated_lane_id_after_editing_a_scanner_whose_null_lane_was_already_cached":7,"P\\Tests\\Api\\EdgeGatewayAgentApiTest::__pest_evaluable_it_serves_installer_artifacts_and_recovers_gateway_runtime_status_after_fresh_heartbeats":1,"P\\Tests\\Api\\EdgeGatewayAgentApiTest::__pest_evaluable_it_polls_operations_and_commands__submits_results__and_records_broker_presence_for_task_pages":7,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_builds_broker_backlog_and_completes_gateway_operations_through_broker_endpoints":8,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_creates_install_tokens__tracks_installer_status__and_exposes_claimed_gateway_detail_to_authorized_operators":1,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_manages_edge_gateway_metadata__bindings__operations__sessions__rotation__cutover__and_deletion":8,"P\\Tests\\Integration\\EdgeGateway\\EdgeGatewayBackendIntegrationTest::__pest_evaluable_it_assembles_tasks__logs__statistics__operations__commands__and_shell_lifecycle_state_from_persisted_records":7,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_rejects_invalid_edge_broker_shared_secrets":1,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_rejects_operator_edge_routes_when_module_permission_or_department_access_is_missing":1,"P\\Tests\\Api\\EdgeGatewayAgentApiTest::__pest_evaluable_it_rejects_missing_and_invalid_edge_agent_tokens":1,"P\\Tests\\Integration\\EdgeGateway\\EdgeGatewayBackendIntegrationTest::__pest_evaluable_it_persists_gateway_cutover_relay_bindings_used_by_self_serve_Shelly_dispatch":7,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_validates_broker_sessions_and_ingests_presence__telemetry__logs__and_shell_lifecycle_data":1,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_rejects_shell_session_creation_while_broker_presence_is_unavailable":1,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_ignores_and_soft_deletes_edge_gateways_whose_department_no_longer_exists":8},"times":{"P\\Tests\\Unit\\Auth\\CreateTokenTest::__pest_evaluable_it_creates_a_64_char_auth_token_for_an_existing_user":0.11,"P\\Tests\\Unit\\Auth\\CreateTokenTest::__pest_evaluable_it_throws_a_clear_exception_when_customer_user_is_missing":0.017,"P\\Tests\\Unit\\Auth\\LegacyAuthScriptParityTest::__pest_evaluable_it_keeps_passkey_challenge_legacy_script_green_during_migration":0.028,"P\\Tests\\Unit\\Auth\\LegacyAuthScriptParityTest::__pest_evaluable_it_keeps_register_CVR_legacy_script_green_during_migration":0.029,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_builds_deterministic_department_digit_map_and_caps_to_9_options":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_resolves_department_ids_by_selected_digit":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_resolves_gate_type_digit_to_entrance_or_exit":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_stores__loads_and_clears_ivr_state_with_ttl":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_validates_state_caller_fingerprint_matching":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_builds_department_and_gate_prompts":0,"P\\Tests\\Unit\\Permissions\\PermissionNodeDefinitionTest::__pest_evaluable_it_creates_permission_nodes_linked_to_subuser_permission_keys":0,"P\\Tests\\Unit\\Permissions\\PermissionNodeDefinitionTest::__pest_evaluable_it_rejects_empty_permission_definitions":0,"P\\Tests\\Unit\\Selfserve\\NormalizationTest::__pest_evaluable_it_normalizes_button_arrays__json_and_csv_inputs":0,"P\\Tests\\Unit\\Selfserve\\NormalizationTest::__pest_evaluable_it_rejects_invalid_button_inputs":0.012,"P\\Tests\\Unit\\Selfserve\\NormalizationTest::__pest_evaluable_it_normalizes_vehicle_type_values_and_null_semantics":0,"P\\Tests\\Unit\\Selfserve\\NormalizationTest::__pest_evaluable_it_rejects_invalid_vehicle_type_values":0,"P\\Tests\\Unit\\Selfserve\\NormalizationTest::__pest_evaluable_it_rejects_non_numeric_vehicle_type_strings":0,"P\\Tests\\Unit\\Selfserve\\NormalizationTest::__pest_evaluable_it_keeps_lane_service_enum_contract_for_MACHINE":0,"P\\Tests\\Unit\\Subusers\\SubuserGrantInitializationTest::__pest_evaluable_it_initializes_booking_node_grants_without_DB_dependency":0,"P\\Tests\\Unit\\Subusers\\SubuserGrantInitializationTest::__pest_evaluable_it_initializes_selfserve_node_grants_without_DB_dependency":0,"P\\Tests\\Unit\\Subusers\\SubusersRoutePermissionLinkTest::__pest_evaluable_it_keeps_subusers_route_list_permission_linked_to_SUBUSERS__LIST_node":0.005,"P\\Tests\\Unit\\Search\\SystemSearchEntityTypeCoverageTest::__pest_evaluable_it_keeps_route_and_service_entity_type_registries_in_sync_with_expanded_coverage":0,"P\\Tests\\Unit\\Search\\SystemSearchEntityTypeCoverageTest::__pest_evaluable_it_documents_every_supported_search_entity_type_in_openapi_enum":0.009,"P\\Tests\\Unit\\Search\\SystemSearchEconomicCustomerIndexWiringTest::__pest_evaluable_it_wires_local_e_conomic_customer_index_into_customer_related_search_entities":0.003,"P\\Tests\\Unit\\Search\\SystemSearchEconomicCustomerIndexWiringTest::__pest_evaluable_it_registers_cron_tasks_that_keep_the_e_conomic_search_index_refreshed":0.003,"P\\Tests\\Unit\\Search\\SystemSearchInvalidationHooksTest::__pest_evaluable_it_marks_system_search_cache_dirty_from_generic_db_object_mutation_flows":0.004,"P\\Tests\\Unit\\Search\\SystemSearchInvalidationHooksTest::__pest_evaluable_it_marks_system_search_cache_dirty_from_object_property_mutations":0.003,"P\\Tests\\Unit\\Search\\SystemSearchInvalidationHooksTest::__pest_evaluable_it_marks_system_search_cache_dirty_when_module_config_values_change":0.002,"P\\Tests\\Unit\\Search\\SystemSearchInvalidationHooksTest::__pest_evaluable_it_registers_cron_maintenance_task_for_system_search_cache":0.002,"P\\Tests\\Unit\\Search\\SystemSearchOpenAiIntentParserTest::__pest_evaluable_it_redacts_obvious_sensitive_fragments_before_sending_query_to_intent_parser":0.001,"P\\Tests\\Unit\\Search\\SystemSearchOpenAiIntentParserTest::__pest_evaluable_it_builds_payload_with_redacted_query_and_parses_strict_JSON_output":0,"P\\Tests\\Unit\\Search\\SystemSearchOpenAiIntentParserTest::__pest_evaluable_it_falls_back_safely_when_OpenAI_is_disabled":0,"P\\Tests\\Unit\\Search\\SystemSearchOpenAiIntentParserTest::__pest_evaluable_it_handles_malformed_OpenAI_response_payloads_without_throwing":0,"P\\Tests\\Unit\\Search\\SystemSearchOpenAiIntentParserTest::__pest_evaluable_it_uses_parser_cache_for_identical_query_and_allowed_type_combinations":0,"P\\Tests\\Unit\\Search\\SystemSearchOpenAiIntentParserTest::__pest_evaluable_it_caps_alias_and_hint_payloads_from_OpenAI_and_filters_hints_to_allowed_types":0.006,"P\\Tests\\Unit\\Search\\SystemSearchOpenApiSpecTest::__pest_evaluable_it_documents_system_wide_search_endpoints_in_openapi":0.007,"P\\Tests\\Unit\\Search\\SystemSearchOpenApiSpecTest::__pest_evaluable_it_documents_debug__intent_and_parser_metadata_schema_in_openapi":0.007,"P\\Tests\\Unit\\Search\\SystemSearchOpenApiSpecTest::__pest_evaluable_it_documents_e_conomic_indexed_customer_matching_and_synonym_behavior":0.005,"P\\Tests\\Unit\\Search\\SystemSearchRouteBehaviorTest::__pest_evaluable_it_normalizes_type_lists_from_csv_json_and_arrays":0,"P\\Tests\\Unit\\Search\\SystemSearchRouteBehaviorTest::__pest_evaluable_it_parses_booleans_and_clamps_integers_using_route_defaults":0,"P\\Tests\\Unit\\Search\\SystemSearchRouteBehaviorTest::__pest_evaluable_it_exposes_expected_searchable_entity_types":0,"P\\Tests\\Unit\\Search\\SystemSearchRouteWiringTest::__pest_evaluable_it_registers_system_wide_search_GET_and_POST_endpoints_with_intent_debug_support":0.003,"P\\Tests\\Unit\\Search\\SystemSearchRouteWiringTest::__pest_evaluable_it_registers_superuser_cache_clear_and_rebuild_endpoints_for_system_search":0.003,"P\\Tests\\Unit\\Search\\SystemSearchRouteWiringTest::__pest_evaluable_it_passes_permission_and_own_scope_context_into_system_search_service":0.003,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_does_not_invoke_intent_parser_when_lexical_confidence_is_already_high":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_invokes_parser_on_low_confidence_lexical_results_and_applies_hints_only_boosts":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_never_lets_parser_entity_hints_override_explicit_include_filters":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_returns_fallback_parser_metadata_when_parser_fails_gracefully":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_scopes_query_cache_by_permission_context_to_avoid_cross_user_cache_leakage":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_caps_AI_driven_expanded_terms_to_prevent_query_amplification":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_expands_danish_discount_wording_into_lexical_discount_synonyms":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_invokes_parser_for_intent_driven_natural_language_queries_even_when_lexical_score_is_high":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_uses_association_hints_to_pull_related_customer_records_from_non_customer_matches":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_prefers_newer_records_when_relevance_scores_are_comparable":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_keeps_explicit_identifier_matches_ahead_of_newer_but_weaker_records":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_promotes_invoices_orders_order_bookings_and_customers_in_ranking":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_never_prioritizes_cancelled_bookings_over_active_bookings":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_heavily_demotes_configured_low_priority_entity_types_in_ranking":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_tokenizes_unicode_names_without_stripping_non_ascii_letters":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_does_not_treat_explicit_identifier_queries_as_intent_driven":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_requires_broader_term_coverage_for_multi_word_scoring":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_enriches_object_attachment_results_with_associated_customer_context":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_includes_the_economic_customer_index_in_cache_dependencies_for_customer_scoped_results":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_falls_back_to_invoice_date_ranges_when_invoice_names_are_missing":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_derives_xlvask_customer_numbers_only_from_digits_only_extern_ids":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_replaces_unnamed_user_titles_with_the_customer_context_name":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_uses_the_goal_criteria_label_for_department_goal_titles":0,"P\\Tests\\Integration\\Search\\SystemSearchCacheIntegrationTest::__pest_evaluable_it_reuses_parser_cache_entries_for_repeated_natural_language_intent_requests":0.106,"P\\Tests\\Integration\\Search\\SystemSearchCacheIntegrationTest::__pest_evaluable_it_clears_parser_cache_namespace_via_clearAll_to_force_a_fresh_parse":0,"P\\Tests\\Integration\\Search\\SystemSearchCacheIntegrationTest::__pest_evaluable_it_bumps_per_table_cache_versions_when_a_dirty_table_marker_is_registered":0.081,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_falls_back_to_system_orders_for_fixed_pricing_when_regular_orders_yield_no_customers":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_falls_back_to_system_orders_for_wash_subscriptions_when_regular_orders_yield_no_customers":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2CliBackfillCommandTest::__pest_evaluable_it_registers_economic_v2_backfill_command_in_cli_dispatcher":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicV2CliBackfillCommandTest::__pest_evaluable_it_provides_a_backfill_cron_script_entrypoint":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicV2CompareEngineTest::__pest_evaluable_it_returns_exact__match_when_totals_lines_and_departments_are_identical":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2CompareEngineTest::__pest_evaluable_it_returns_total__mismatch_when_only_totals_differ":0.015,"P\\Tests\\Unit\\Invoicing\\EconomicV2CompareEngineTest::__pest_evaluable_it_detects_line_level_mismatches_for_quantity_and_price":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2CompareEngineTest::__pest_evaluable_it_detects_departmental_distribution_mismatches":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2CompareEngineTest::__pest_evaluable_it_returns_missing__target_when_draft_or_booked_target_is_unavailable":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2LineNormalizerTest::__pest_evaluable_it_normalizes_draft_invoice_lines_including_departmental_distributions":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2LineNormalizerTest::__pest_evaluable_it_marks_text_only_zero_value_lines_as_non_billable_and_keeps_deterministic_key":0.008,"P\\Tests\\Unit\\Invoicing\\EconomicV2LineNormalizerTest::__pest_evaluable_it_normalizes_booked_invoices_and_computes_net_total_delta_from_lines":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2LineNormalizerTest::__pest_evaluable_it_contains_internal_normalization_path_with_departmental_metadata_support":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_documents_economic_v2_invoice_paths_in_openapi":0.009,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_documents_v2_historical_distribution_and_pricing_history_paths_in_openapi":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_aligns_legacy_compare_schema_with_runtime_payload_by_removing_stale_required_order__ids":0.003,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_defines_new_reusable_v2_schemas_for_normalization_comparison_versioning_and_distribution":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicV2RevenueAndBarredSupportTest::__pest_evaluable_it_supports_barred_filtering_when_listing_e_conomic_customers":0.006,"P\\Tests\\Unit\\Invoicing\\EconomicV2RevenueAndBarredSupportTest::__pest_evaluable_it_implements_a_dedicated_v2_e_conomic_revenue_statistics_service_and_route":0.006,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_registers_all_collected_invoice_economic_v2_routes_with_explicit_permissions":0.003,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_registers_version_aware_distribution_and_pricing_history_v2_routes":0.003,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_caches_v2_distribution_responses_with_a_configurable_redis_ttl":0.003,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_writes_fixed_pricing_versions_from_create_and_delete_flows":0.003,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_writes_vehicle_subscription_versions_for_create_update_delete_flows":0.003,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_writes_discount_override_versions_from_superuser_discounts_route":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_keeps_legacy_compare_endpoint_path_for_backward_compatibility":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicV2VersioningServiceStructureTest::__pest_evaluable_it_implements_effective_range_lifecycle_methods_for_all_versioned_entities":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicV2VersioningServiceStructureTest::__pest_evaluable_it_closes_previous_active_interval_before_inserting_a_new_version":0.003,"P\\Tests\\Unit\\Invoicing\\EconomicV2VersioningServiceStructureTest::__pest_evaluable_it_includes_best_effort_backfill_with_provenance_and_confidence_metadata":0.003,"P\\Tests\\Unit\\Invoicing\\EconomicV2VersioningServiceStructureTest::__pest_evaluable_it_anchors_historical_resolution_on_order_created__at_timestamps_in_distribution_service":0.004,"P\\Tests\\Unit\\Invoicing\\InvoicingOrdersCalculationsHardeningTest::__pest_evaluable_it_guards_against_empty_order_ids_in_net_amount_calculation":0.007,"P\\Tests\\Unit\\Invoicing\\InvoicingOrdersCalculationsHardeningTest::__pest_evaluable_it_guards_against_empty_customer_arrays_in_date_range_transaction_fetches":0.003,"P\\Tests\\Unit\\Invoicing\\InvoicingOrdersCalculationsHardeningTest::__pest_evaluable_it_uses_centralized_duplicate_filtering_for_possible_duplicate_detection":0.003,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteCacheHelpersTest::__pest_evaluable_it_uses_sane_ttl_defaults_and_clamps_negative_ttl_to_zero":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteCacheHelpersTest::__pest_evaluable_it_builds_deterministic_cache_keys_per_scope_and_date_range":0.007,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteCacheHelpersTest::__pest_evaluable_it_falls_back_to_resolver_directly_when_cache_ttl_is_disabled":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteGuardsTest::__pest_evaluable_it_requires_superuser_permission_for_invoicing_period_distribution_all_endpoint":0.017,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteGuardsTest::__pest_evaluable_it_uses_shared_date_range_normalization_across_invoicing_period_endpoints":0.032,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodUtilsTest::__pest_evaluable_it_normalizes_a_valid_invoicing_date_range_to_full_day_timestamps":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodUtilsTest::__pest_evaluable_it_rejects_invalid_date_formats":0.012,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodUtilsTest::__pest_evaluable_it_rejects_descending_date_ranges":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodUtilsTest::__pest_evaluable_it_finds_duplicate_orders_regardless_of_original_input_order":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_runs_best_effort_backfill_when_fixed_pricing_version_history_is_empty":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConditionEvaluatorTest::__pest_evaluable_it_evaluates_self_serve_conditions_with_combined_AND_and_OR_semantics":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConditionEvaluatorTest::__pest_evaluable_it_prefers_condition_results_over_question_answers_when_evaluating_task_gates":0,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_self_serve_machine_type__eligibility__summary__and_webhook_endpoints":0.008,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_defines_reusable_self_serve_wash_and_machine_type_schemas":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_self_serve_machine_types__eligibility__summaries__and_machine_start_webhook_routes":0.084,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_keeps_machine_type_support_wired_into_lanes__tasks__and_conditions_routes":0.024,"P\\Tests\\Unit\\N8n\\N8nRouteHelpersTest::__pest_evaluable_it_normalizes_webhook_methods_and_falls_back_to_POST_for_unsupported_verbs":0,"P\\Tests\\Unit\\N8n\\N8nRouteHelpersTest::__pest_evaluable_it_filters_request_parameters_down_to_the_allowed_n8n_query_keys":0,"P\\Tests\\Unit\\N8n\\N8nRouteWiringTest::__pest_evaluable_it_registers_workflow_lifecycle_endpoints_for_the_n8n_module":0.002,"P\\Tests\\Unit\\N8n\\N8nRouteWiringTest::__pest_evaluable_it_registers_execution_and_webhook_trigger_endpoints_for_the_n8n_module":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_redistributes_booked_department_75_net_amounts_using_fixed_pricing_and_wash_subscription_weights":0.008,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_keeps_classified_booked_department_75_amounts_undistributed_when_no_monthly_basis_exists":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_keeps_unclassified_booked_department_75_lines_undistributed_with_warnings":0,"P\\Tests\\Unit\\Invoicing\\EconomicEndpointUrlEncodingTest::__pest_evaluable_it_encodes_raw_filter_query_values_that_include_timestamps":0,"P\\Tests\\Unit\\Invoicing\\EconomicEndpointUrlEncodingTest::__pest_evaluable_it_avoids_double_encoding_query_values_that_are_already_escaped":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_supports_bulk_booked_invoice_line_payloads_with_top_level_department_numbers":0,"P\\Tests\\Unit\\Redis\\RedisAtomicReservationTest::__pest_evaluable_it_claims_a_slot_atomically_with_nx_and_expiration":0,"P\\Tests\\Unit\\Redis\\RedisAtomicReservationTest::__pest_evaluable_it_returns_false_when_the_slot_is_already_claimed_and_clamps_ttl_to_one_second":0,"P\\Tests\\Unit\\Bookings\\OrderBookingRouteIdempotencyGuardTest::__pest_evaluable_it_guards_order_booking_creation_with_redis_backed_idempotency":0.24,"P\\Tests\\Unit\\DynamicImages\\DynamicImagePreRenderCronWiringTest::__pest_evaluable_it_registers_dynamic_image_pre_render_cron_task_and_related_helpers":0.005,"P\\Tests\\Integration\\Database\\DbConnectionTest::__pest_evaluable_it_can_connect_to_a_configured_MySQL_instance_in_integration_mode":0.226,"P\\Tests\\Integration\\Invoicing\\EconomicV2BackfillAndDistributionIntegrationTest::__pest_evaluable_it_runs_best_effort_backfill_repeatedly_without_introducing_duplicate_same_start_rows":0.017,"P\\Tests\\Integration\\Invoicing\\EconomicV2BackfillAndDistributionIntegrationTest::__pest_evaluable_it_resolves_version_aware_distribution_payload_shapes_over_a_real_date_range":0.014,"P\\Tests\\Integration\\Invoicing\\EconomicV2VersioningServiceIntegrationTest::__pest_evaluable_it_creates_closes_and_rotates_fixed_pricing_versions_without_overlap":0.011,"P\\Tests\\Integration\\Invoicing\\EconomicV2VersioningServiceIntegrationTest::__pest_evaluable_it_tracks_vehicle_and_discount_version_lifecycles_with_closure_semantics":0.016,"P\\Tests\\Integration\\Permissions\\LegacyPermissionRedisCacheScriptTest::__pest_evaluable_it_keeps_legacy_route_permission_redis_cache_script_green":0.013,"P\\Tests\\Unit\\Permissions\\AllowOwnOrDepartmentAccessForbiddenTest::__pest_evaluable_it_returns_both_own_and_elevated_permissions_when_both_are_missing":0,"P\\Tests\\Unit\\Permissions\\AllowOwnOrDepartmentAccessForbiddenTest::__pest_evaluable_it_returns_only_elevated_permission_when_own_permission_exists_but_own_context_fails":0,"P\\Tests\\Unit\\Permissions\\AllowOwnOrDepartmentAccessForbiddenTest::__pest_evaluable_it_allows_elevated_permission_path_without_forbidden_and_validates_department_access":0,"P\\Tests\\Unit\\Permissions\\ForbiddenResponseWiringTest::__pest_evaluable_it_routes_and_trait_use_forbidden_responses_for_permission_and_ownership_denials":0.018,"P\\Tests\\Unit\\Permissions\\GroupPermissionSessionInvalidationWiringTest::__pest_evaluable_it_invalidates_related_user_permission_and_auth_session_caches_when_group_permissions_change":0.004,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetDurationTest::__pest_evaluable_it_parses_advanced_target_duration_from_snake__case_fields":0.066,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetDurationTest::__pest_evaluable_it_parses_advanced_target_duration_from_camelCase_aliases":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetDurationTest::__pest_evaluable_it_normalizes_ENTIRE__DURATION_to_ignore_target__duration__every":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetDurationTest::__pest_evaluable_it_defaults_recurring_target__duration__every_to_1_when_omitted":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetDurationTest::__pest_evaluable_it_keeps_strict_legacy_mode_when_target__duration_is_invalid":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetMathTest::__pest_evaluable_it_computes_weekly_target_using_touched_ISO_weeks_for_March_2026":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetMathTest::__pest_evaluable_it_applies_target__duration__every_for_weekly_cadence":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetMathTest::__pest_evaluable_it_prorates_ENTIRE__DURATION_target_by_overlap_days":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetMathTest::__pest_evaluable_it_splits_advanced_target_by_override_weights_per_department":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetMathTest::__pest_evaluable_it_preserves_legacy_target_behavior_when_target__duration_is_missing":0,"P\\Tests\\Unit\\Goals\\GoalsLegacyRendererScriptsTest::__pest_evaluable_it_keeps_legacy_monthly_renderer_script_green":0.147,"P\\Tests\\Unit\\Goals\\GoalsLegacyRendererScriptsTest::__pest_evaluable_it_keeps_legacy_department_daily_renderer_script_green":0.147,"P\\Tests\\Unit\\Goals\\GoalsOpenApiSpecTest::__pest_evaluable_it_documents_advanced_goals_target_duration_fields_in_openapi":0.007,"P\\Tests\\Unit\\Workfeed\\WorkfeedClientConformanceTest::__pest_evaluable_it_uses_company_scoped_workfeed_endpoints_and_raw_Authorization_header":0.003,"P\\Tests\\Unit\\Workfeed\\WorkfeedClientConformanceTest::__pest_evaluable_it_normalizes_documented_shift_query_parameters":0.001,"P\\Tests\\Unit\\Workfeed\\WorkfeedConfigRouteWiringTest::__pest_evaluable_it_registers_module_config_endpoints_for_workfeed":0.004,"P\\Tests\\Unit\\Workfeed\\WorkfeedRouteHelpersTest::__pest_evaluable_it_filters_request_parameters_down_to_the_allowed_workfeed_query_keys":0,"P\\Tests\\Unit\\Workfeed\\WorkfeedRouteWiringTest::__pest_evaluable_it_registers_employee_and_department_endpoints_for_the_workfeed_module":0.004,"P\\Tests\\Unit\\Workfeed\\WorkfeedRouteWiringTest::__pest_evaluable_it_registers_shift_endpoints_for_the_workfeed_module":0.002,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_calculates_workfeed_employee_hours_for_the_hour_slot_based_on_overlap":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_extracts_department_id_from_supported_workfeed_shift_shapes":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_normalizes_wrapped_workfeed_collections_from_common_response_keys":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineSchemaConformanceTest::__pest_evaluable_it_includes_a_date_key_in_department_weather_timeline_entries":0.003,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineSchemaConformanceTest::__pest_evaluable_it_documents_the_date_key_in_the_openapi_department_weather_timeline_schema":0.005,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineRangeTest::__pest_evaluable_it_builds_a_timeline_range_from_start_of_yesterday_to_end_of_today":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineRangeTest::__pest_evaluable_it_builds_a_timeline_range_relative_to_an_explicit_selected_date_override":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_calculates_workfeed_employee_hours_across_multiple_departments_for_one_hour_slot":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_normalizes_department_id_input_from_scalar_csv_and_nested_array_values":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineRangeTest::__pest_evaluable_it_builds_a_timeline_range_relative_to_explicit_date__from_and_date__to_override":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineRangeTest::__pest_evaluable_it_resolves_forecast_day_count_for_a_given_timeline_range_with_sane_limits":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_resolves_weather_coordinates_from_valid_coordinates_only":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_returns_null_weather_coordinates_when_all_department_locations_are_invalid":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_classifies_weatherapi_no_matching_location_errors_as_location_lookup_failures":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_does_not_classify_non_location_weatherapi_errors_as_location_lookup_failures":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_returns_an_empty_forecast_fallback_when_coordinates_are_unavailable":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_builds_timeline_entries_with_mostly__clear_weather_when_forecast_payload_is_empty":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_wires_departments_weather_route_through_the_forecast_fallback_path":0.005,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_uses_sane_ttl_defaults_and_clamps_negative_ttl_to_zero":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_builds_deterministic_cache_keys_for_department_sets_and_timeline_ranges":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_builds_order_insensitive_cache_keys_for_equivalent_department_id_sets":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_falls_back_to_resolver_directly_when_cache_ttl_is_disabled":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineRangeTest::__pest_evaluable_it_marks_non_started_slots_as_unknown_regardless_of_wash_hour_ratio":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherPreloadCronWiringTest::__pest_evaluable_it_registers_and_implements_cron_preloading_for_department_weather_cache":0.007,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_uses_sane_stale_ttl_defaults_and_clamps_stale_ttl_below_fresh_ttl":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_uses_sane_hot_activity_ttl_defaults_and_clamps_to_a_positive_value":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheRuntimeBehaviorTest::__pest_evaluable_it_serves_fresh_cached_payloads_without_invoking_the_resolver":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheRuntimeBehaviorTest::__pest_evaluable_it_serves_stale_cached_payloads_and_enqueues_a_refresh_signal":0.01,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheRuntimeBehaviorTest::__pest_evaluable_it_recomputes_and_rewrites_cache_payloads_when_stale_window_is_exceeded":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheRuntimeBehaviorTest::__pest_evaluable_it_records_hot_keys_order_insensitively_and_applies_preload_target_caps":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_normalizes_batched_wash_count_rows_into_hourly_slot_totals":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_wires_department_weather_route_to_use_batched_wash_aggregation":0.007,"P\\Tests\\Unit\\Orders\\AttachmentsListManyTest::__pest_evaluable_it_groups_attachments_by_object_id_and_preserves_empty_object_groups":0,"P\\Tests\\Unit\\Orders\\AttachmentsListManyTest::__pest_evaluable_it_keeps_listMany_payload_parity_with_list_for_one_object_id":0,"P\\Tests\\Unit\\Orders\\EconomicModuleOrdersBatchHelpersTest::__pest_evaluable_it_ensures_economic_module_rows_in_one_sanitized_batch":0,"P\\Tests\\Unit\\Orders\\EconomicModuleOrdersBatchHelpersTest::__pest_evaluable_it_returns_id_keyed_economic_module_payload_with_null_defaults":0,"P\\Tests\\Unit\\Orders\\OrdersRouteListBatchingWiringTest::__pest_evaluable_it_wires_GET__orders_through_batched_enrichment_and_stripe_snapshot_caching":0.006,"P\\Tests\\Unit\\Orders\\UsersCashierNamesBatchTest::__pest_evaluable_it_returns_cashier_names_from_cache_and_fetches_missing_ones_in_batch":0,"P\\Tests\\Unit\\Orders\\UsersCashierNamesBatchTest::__pest_evaluable_it_returns_deterministic_map_for_duplicate_and_unsorted_cashier_ids":0,"P\\Tests\\Unit\\Orders\\UsersCashierNamesBatchWiringTest::__pest_evaluable_it_implements_batched_cashier_name_lookup_with_cache_first_fallback_behavior":0.006,"P\\Tests\\Unit\\Orders\\UsersCashierNamesBatchWiringTest::__pest_evaluable_it_sanitizes_and_deduplicates_cashier_ids_before_batch_lookup":0.003,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_counts_overtime_minutes_when_a_shift_carries_an_extended_approval_end_timestamp":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_counts_unapproved_overtime_from_a_bounded_shift_updateTime_fallback":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_does_not_treat_late_unapproved_administrative_edits_as_overtime":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_changes_weather_timeline_cache_key_when_department_weather_targets_change":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_evaluates_healthy_degraded_and_unhealthy_statuses_from_configured_department_targets":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_returns_unknown_when_configured_targets_are_missing_for_department_status_evaluation":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_aggregates_multi_department_slot_statuses_using_worst_severity_and_unknown_fallback_rules":0.046,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsOpenApiSpecTest::__pest_evaluable_it_documents_department_weather_target_endpoints_and_reusable_schemas_in_openapi":0.006,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsOpenApiSpecTest::__pest_evaluable_it_documents_unknown_weather_status_behavior_when_targets_are_missing":0.005,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsRouteWiringTest::__pest_evaluable_it_registers_department_weather_target_routes_with_explicit_read_and_manage_permissions":0.004,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsRouteWiringTest::__pest_evaluable_it_persists_department_weather_targets_using_canonical_department_variable_keys":0.003,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_machine_relay_status_get_and_set_endpoints":0.005,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_batches_sequential_machine_relay_status_requests_into_a_single_Shelly_get_call":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_respects_the_1_request_per_second_Shelly_gate_for_back_to_back_requests":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_retries_missing_Shelly_status_payloads_until_relay_status_becomes_ready":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_times_out_with_a_clear_Shelly_readiness_error_when_payload_remains_missing":0.001,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_uses_direct_set_switch_and_seeds_cache_so_immediate_status_read_does_not_call_Shelly_get":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_self_serve_lane_gate_open_endpoint":0.004,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_in_progress_self_serve_wash_details_endpoint":0.003,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_adds_vehicle_type_product_on_STOP_when_program_selector_is_on__then_turns_off_cleaner_machine_and_selector_relays":0.132,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_skips_vehicle_type_product_add_when_program_selector_is_off_and_only_disables_configured_relays":0.001,"P\\Tests\\Unit\\Selfserve\\DepartmentSelfServeEnabledRelaySyncWiringTest::__pest_evaluable_it_syncs_lane_relay_states_when_department_self_serve_enabled_flag_changes":0.005,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_supports_hard_relay_set_even_when_lane_status_is_CLOSED":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStartCleanerRelayWiringTest::__pest_evaluable_it_enables_cleaner_relay_on_wash_start_command_and_webhook_flow":0.009,"P\\Tests\\Unit\\Selfserve\\SelfserveWashCompletionRelayWiringTest::__pest_evaluable_it_forces_machine_relay_off_when_a_self_serve_wash_session_is_completed":0.003,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_opens_exit_port_by_switching_relay__in__id_on":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_opens_entrance_port_by_switching_relay__out__id_on":0,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_in_progress_self_serve_wash_start_and_machine_relay_fields":0.006,"P\\Tests\\Unit\\Selfserve\\SelfserveConditionEvaluatorTest::__pest_evaluable_it_evaluates_typed_task_gates_with_strict_semantics":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_validates_typed_task_gates_for_known_references":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_fails_validation_when_typed_task_gates_reference_unknown_entities":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_self_serve_config_draft_publish_rollback_lifecycle_endpoints":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_keeps_legacy_self_serve_CRUD_routes_syncing_canonical_drafts":0.017,"P\\Tests\\Unit\\Selfserve\\SelfserveStartCleanerRelayWiringTest::__pest_evaluable_it_keeps_cleaner_relay_enable_wired_into_machine_relay_start_paths":0.008,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_stores_config_json_with_apostrophes_without_violating_json_constraints":0.277,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_encodes_config_json_payloads_with_apostrophes_before_persistence":0,"P\\Tests\\Unit\\Selfserve\\SelfserveWashCompletionRelayWiringTest::__pest_evaluable_it_forces_machine_and_cleaner_relays_off_when_a_self_serve_wash_session_is_completed":0.005,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_vehicle_type_override_into_self_serve_preview_and_synchronization_routes":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_uses_local_demo_responses_and_skips_Shelly_cloud_calls_for_demo_relay_ids":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_returns_default_OFF_status_for_demo_relay_ids_without_Shelly_lookups":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_excludes_demo_relay_ids_from_Shelly_status_batch_payloads":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_keeps_demo_relay_gate_open_queued_but_skips_Shelly_switch_calls":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_allowed_services_route_through_machine_relay_visibility_sync":0.002,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_enables_MACHINE_relay_when_MACHINE_is_visible_in_allowed_services":0.002,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_turns_MACHINE_relay_off_immediately_when_MACHINE_is_not_visible":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_does_not_enable_MACHINE_relay_when_allowEnable_is_false_even_if_MACHINE_is_visible":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_is_a_safe_no_op_when_MACHINE_relay_is_not_configured":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRelayVisibilitySyncWiringTest::__pest_evaluable_it_synchronizes_machine_relay_from_visible_services_during_session_synchronization":0.01,"P\\Tests\\Unit\\Selfserve\\SelfserveRelayVisibilitySyncWiringTest::__pest_evaluable_it_adds_explicit_relay_disable_session_helper_for_visibility_driven_OFF_transitions":0.005,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_self_serve_property_gate_command_permissions":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneCommandEnumTest::__pest_evaluable_it_parses_property_gate_lane_commands":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePropertyGateCommandTest::__pest_evaluable_it_opens_entrance_gate_for_OPEN__PROPERTY__ACCESS__GATE_command":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePropertyGateCommandTest::__pest_evaluable_it_opens_exit_gate_for_OPEN__PROPERTY__EXIT__GATE_command":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePropertyGateCommandTest::__pest_evaluable_it_blocks_property_gate_commands_when_department_self_serve_is_disabled":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePropertyGateCommandTest::__pest_evaluable_it_throws_a_clear_error_when_entrance_gate_is_missing_for_property_access_command":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePropertyGateCommandTest::__pest_evaluable_it_wraps_low_level_gate_errors_for_property_access_command_failures":0.002,"P\\Tests\\Unit\\Selfserve\\SelfserveWashFlowMachineAllowedWiringTest::__pest_evaluable_it_reads_machine_allowed_state_from_session_summary_payload_safely":0.004,"P\\Tests\\Unit\\Selfserve\\DbObjectPaginationLegacyColumnCompatibilityTest::__pest_evaluable_it_guards_pagination_against_searchable_fields_missing_from_legacy_schemas":0.339,"P\\Tests\\Unit\\Selfserve\\SelfserveSchemaBootstrapCompatibilityTest::__pest_evaluable_it_adds_dynamic__images__vehicle__type_column_for_legacy_selfserve_wash_session_task_schemas":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneInvoiceIncludedMinutesTest::__pest_evaluable_it_computes_billable_minutes_when_elapsed_minutes_exceed_included_minutes":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneInvoiceIncludedMinutesTest::__pest_evaluable_it_computes_zero_billable_minutes_when_elapsed_minutes_equal_included_minutes":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneInvoiceIncludedMinutesTest::__pest_evaluable_it_computes_zero_billable_minutes_when_included_minutes_exceed_elapsed_minutes":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneInvoiceIncludedMinutesTest::__pest_evaluable_it_handles_zero_and_low_elapsed_wash_time_edge_cases":0,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_self_serve_machine_wash_included_minutes_in_config_schemas":0.006,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_machine_wash_included_minutes_into_self_serve_module_config":0.005,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_blocks_explicit_relay_writes_when_department_self_serve_is_disabled":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_returns_disabled_no_op_from_machine_relay_visibility_sync_when_department_self_serve_is_disabled":0,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_property_gate_lane_commands_and_sanitized_gate_failure_responses":0.008,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_fast_when_workspace_id_is_missing_for_gate_calls":0.019,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_fast_when_channel_id_is_missing_for_gate_calls":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_with_terminal_status_details_when_call_never_reaches_accepted_state":0.072,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_hangs_up_when_call_reaches_accepted_state":0.013,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_hangs_up_immediately_when_status_transitions_to_accepted":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_maps_legacy_timeout_option_to_documented_ringTimeout_payload_field":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_clamps_derived_ringTimeout_to_Bird_documented_max_when_gate_timeout_is_high":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_passes_documented_hangup_cause_when_provided":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_drops_unsupported_hangup_cause_values_from_request_payload":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_fast_when_workspace_id_is_missing_for_gate_flash_calls":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_creates_gate_flash_call_with_documented_ringTimeout_payload":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_polls_flash_call_and_succeeds_once_accepted":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_flash_gate_flow_when_terminal_failure_status_is_returned":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_does_not_fallback_to_regular_gate_call_when_flash_succeeds":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_falls_back_to_regular_gate_call_when_flash_fails":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePropertyGateCommandTest::__pest_evaluable_it_wraps_low_level_gate_errors_for_property_exit_command_failures":0.002,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_falls_back_to_regular_gate_call_when_flash_caller_id_is_not_confirmed":0,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_maps_voice_call_facade_methods_to_documented_endpoints_and_methods":0.039,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_maps_recordings_insights_log_and_flash_methods_to_documented_resources":0,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_encodes_path_segments_before_building_outbound_endpoints":0,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_serializes_query_parameters_for_GET_transport_requests":0,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_maps_4xx_and_5xx_transport_failures_into_informative_exceptions":0,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_returns_raw_payload_for_malformed_JSON_responses_and_null_for_empty_responses":0.007,"P\\Tests\\Unit\\Bird\\BirdOpenApiSpecTest::__pest_evaluable_it_documents_all_Bird_voice_call_parity_endpoints_including_gather_recordings_insights_and_log":0.007,"P\\Tests\\Unit\\Bird\\BirdOpenApiSpecTest::__pest_evaluable_it_documents_flash_hangup_endpoint_and_marks_end_alias_as_deprecated":0.005,"P\\Tests\\Unit\\Bird\\BirdOpenApiSpecTest::__pest_evaluable_it_defines_request_and_response_schemas_for_Bird_call_command_recording_insight_log_and_flash_payloads":0.004,"P\\Tests\\Unit\\Bird\\BirdRequestValidationTest::__pest_evaluable_it_accepts_valid_payloads_for_create_and_nested_call_flow_commands":0,"P\\Tests\\Unit\\Bird\\BirdRequestValidationTest::__pest_evaluable_it_rejects_unknown_fields_and_invalid_enum_values_in_strict_schemas":0.014,"P\\Tests\\Unit\\Bird\\BirdRequestValidationTest::__pest_evaluable_it_supports_CSV_normalization_in_schema_validation_for_log_filters":0,"P\\Tests\\Unit\\Bird\\BirdRequestValidationTest::__pest_evaluable_it_validates_flash_hangup_payloads_for_both_supported_request_shapes":0,"P\\Tests\\Unit\\Bird\\BirdRequestValidationTest::__pest_evaluable_it_fails_before_forward_step_when_strict_validation_fails_and_forwards_when_valid":0,"P\\Tests\\Unit\\Bird\\BirdRouteWiringTest::__pest_evaluable_it_registers_full_Bird_voice_call_route_surface_with_strict_validation_and_permissions":0.005,"P\\Tests\\Unit\\Bird\\BirdRouteWiringTest::__pest_evaluable_it_registers_flash_hangup_endpoint_and_keeps_end_alias_wired_as_compatibility_path":0.004,"P\\Tests\\Unit\\Bird\\BirdRouteWiringTest::__pest_evaluable_it_keeps_Bird_number_and_webhook_routes_intact":0.006,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_handles_malformed_and_empty_response_bodies_without_throwing_transport_errors":0.005,"P\\Tests\\Unit\\Selfserve\\DepartmentGatesRelaysRouteWiringTest::__pest_evaluable_it_validates_department_gate_config_on_both_create_and_update_routes":0.043,"P\\Tests\\Unit\\Selfserve\\SelfserveSchemaBootstrapCompatibilityTest::__pest_evaluable_it_adds_wash__started__at_column_for_legacy_selfserve_wash_session_schemas":0.004,"P\\Tests\\Unit\\Selfserve\\SelfserveWashStartedAtWiringTest::__pest_evaluable_it_stores_wash__started__at_in_self_serve_wash_sessions_when_machine_start_is_triggered":0.003,"P\\Tests\\Unit\\Selfserve\\SelfserveWashStartedAtWiringTest::__pest_evaluable_it_passes_lane_wash_start_time_into_the_session_machine_start_marker":0.004,"P\\Tests\\Unit\\Selfserve\\SelfserveWashStartedAtWiringTest::__pest_evaluable_it_resolves_in_progress_wash__started__at_from_session_with_compatibility_fallbacks":0.003,"P\\Tests\\Unit\\Bird\\BirdPayloadClassesTest::__pest_evaluable_it_normalizes_say_payload_and_applies_hangup_default":0.019,"P\\Tests\\Unit\\Bird\\BirdPayloadClassesTest::__pest_evaluable_it_normalizes_log_query_csv_and_integer_fields":0,"P\\Tests\\Unit\\Bird\\BirdPayloadClassesTest::__pest_evaluable_it_normalizes_nested_create_call_payload_sections_and_drops_unknown_keys":0,"P\\Tests\\Unit\\Bird\\BirdPayloadClassesTest::__pest_evaluable_it_keeps_gather_nested_say_payload_unchanged_when_hangup_is_omitted":0,"P\\Tests\\Unit\\Bird\\BirdPayloadClassesTest::__pest_evaluable_it_supports_no_body_and_flash_hangup_payload_normalization":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_respects_the_2_second_Shelly_gate_for_back_to_back_requests":0.023,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_enforces_a_2_second_gap_between_sequential_switch_requests_used_by_wash_start_and_stop_flows":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_keeps_back_to_back_get_switch_requests_ordered_through_the_relay_controller":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_executes_sequential_switch_requests_used_by_wash_start_and_stop_flows":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_does_not_print_Shelly_switch_responses_to_output":0.067,"P\\Tests\\Unit\\Selfserve\\ShellyGlobalRateLimitWiringTest::__pest_evaluable_it_enforces_a_global_2_second_Shelly_gate_in_sendPostRequest":0.005,"P\\Tests\\Unit\\Selfserve\\ShellyGlobalRateLimitWiringTest::__pest_evaluable_it_uses_Redis_NX_PX_semantics_for_cross_request_Shelly_rate_limiting":0.002,"P\\Tests\\Unit\\Selfserve\\ShellyGlobalRateLimitBehaviorTest::__pest_evaluable_it_enforces_the_2_second_Shelly_gate_across_separate_request_contexts":0,"P\\Tests\\Unit\\Selfserve\\ShellyGlobalRateLimitBehaviorTest::__pest_evaluable_it_does_not_delay_when_the_Shelly_gate_is_already_expired":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_adds_vehicle_type_product_on_STOP_when_program_selector_relay_is_online__then_turns_off_cleaner_and_machine_relays":0.538,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_skips_vehicle_type_product_add_when_program_selector_relay_is_offline_and_only_disables_configured_relays":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_bills_primary_product_when_selector_relay_is_online_even_if_relay_output_is_off":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneInvoiceModeBillingTest::__pest_evaluable_it_bills_manual_self_serve_stop_using_full_elapsed_minutes_without_included_minute_reduction":0.001,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneInvoiceModeBillingTest::__pest_evaluable_it_keeps_included_minute_reduction_for_automatic_mode":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_sends_Slack_on_first_dtmf_input_captured_within_the_300_second_window":0.005,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_does_not_send_Slack_when_dtmf_input_repeats_without_change":0.008,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_sends_Slack_again_when_dtmf_input_changes_within_the_window":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_keeps_gathering_and_does_not_hang_up_before_300_seconds_have_elapsed":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_announces_timeout_waits_10_seconds_and_sends_hangup_once_at_or_after_300_seconds":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_keeps_polling_until_terminal_status_and_only_then_finalizes":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_finalizes_immediately_when_webhook_payload_is_already_terminal_before_timeout_logic":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_does_not_duplicate_timeout_or_hangup_actions_across_retries_and_lock_contention":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_keeps_state_when_polling_fails_so_completion_is_not_falsely_reported":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_sends_Slack_when_dtmf_input_repeats_without_change":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_answers_immediately_once_and_does_not_re_answer_on_subsequent_webhook_retries":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_configures_gather_with_retry_loop_semantics_until_input_is_entered":0.004,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_sends_Slack_when_gather_result_provides_keys_field_instead_of_dtmf":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_sends_Slack_when_gather_conditions_variable_keys_carries_the_entered_value":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_configures_gather_to_check_every_2_seconds_with_retry_loop_semantics_until_input_is_entered":0,"P\\Tests\\Unit\\Users\\UsersAutomaticGetTargetUserFromRequestTest::__pest_evaluable_it_ignores_non_numeric_customer__number_values_from_request":0.042,"P\\Tests\\Unit\\Users\\UsersAutomaticGetTargetUserFromRequestTest::__pest_evaluable_it_casts_numeric_customer__number_strings_to_int_before_lookup":0,"P\\Tests\\Unit\\Users\\UsersAutomaticGetTargetUserFromRequestTest::__pest_evaluable_it_prefers_user__id_when_both_user__id_and_customer__number_are_valid":0,"P\\Tests\\Unit\\Users\\UsersAutomaticGetTargetUserFromRequestTest::__pest_evaluable_it_falls_back_to_customer__number_when_user__id_is_invalid":0,"P\\Tests\\Unit\\Users\\UsersAutomaticGetTargetUserFromRequestTest::__pest_evaluable_it_normalizes_request_identifiers_before_user_lookups":0.004,"P\\Tests\\Unit\\Users\\UsersAutomaticGetTargetUserFromRequestTest::__pest_evaluable_it_rejects_non_positive_and_non_digit_request_values":0.003,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_uses_pagination_results_when_present":0.006,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_falls_back_to_collection_count_when_pagination_metadata_is_missing":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_returns_zero_when_neither_pagination_nor_collection_is_available":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_counts_object_based_collections_when_pagination_metadata_is_missing":0,"P\\Tests\\Unit\\Invoicing\\EconomicPaymentTermsRouteCollectionExtractionTest::__pest_evaluable_it_uses_collection_when_present":0,"P\\Tests\\Unit\\Invoicing\\EconomicPaymentTermsRouteCollectionExtractionTest::__pest_evaluable_it_falls_back_to_paymentTerms_when_collection_is_missing":0,"P\\Tests\\Unit\\Invoicing\\EconomicPaymentTermsRouteCollectionExtractionTest::__pest_evaluable_it_supports_top_level_array_payloads":0,"P\\Tests\\Unit\\Invoicing\\EconomicPaymentTermsRouteCollectionExtractionTest::__pest_evaluable_it_returns_an_empty_array_when_no_known_collection_shape_exists":0,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_uses_grant__2_in_economic__endpoint__t_when_explicitly_requested_and_configured":0.009,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_falls_back_to_grant__1_in_economic__endpoint__t_when_grant__2_is_missing":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_throws_for_missing_required_primary_grant_in_economic__endpoint__t":0.014,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_throws_for_missing_app_secret_in_economic__endpoint__t":0,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_uses_grant__2_in_legacy_economic__m_when_available":0,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_falls_back_to_grant__1_in_legacy_economic__m_when_grant__2_is_missing":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_throws_for_missing_required_primary_grant_in_legacy_economic__m":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_throws_for_missing_app_secret_in_legacy_economic__m":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_uses_unfiltered_total_when_no_customer_filters_are_active":0.004,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_uses_filtered_total_when_search_is_active":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_uses_filtered_total_when_barred_filter_is_active":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_treats__null__search_and_barred_values_as_no_filter":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_falls_back_to_filtered_total_when_unfiltered_total_is_missing":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRouteEconomicFailureHandlingTest::__pest_evaluable_it_maps_e_conomic_integration_failures_in_customer_listing_to_explicit_502_responses":0.014,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRouteEconomicFailureHandlingTest::__pest_evaluable_it_logs_LIST__CUSTOMERS_success_only_after_pagination_and_customer_mapping_are_completed":0.009,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRouteEconomicFailureHandlingTest::__pest_evaluable_it_guards_against_malformed_customer_list_payloads_before_calling_paginate":0.011,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_throws_deterministic_upstream_exceptions_for_non_2xx_endpoint_trait_responses":0,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_throws_deterministic_upstream_exceptions_for_non_2xx_legacy_economic__m_responses":0,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_returns_raw_payload_unchanged_on_successful_HTTP_statuses":0,"P\\Tests\\Unit\\Invoicing\\EconomicCustomersListResponseValidationTest::__pest_evaluable_it_accepts_valid_list_payloads_that_include_collection_and_pagination_results":0.02,"P\\Tests\\Unit\\Invoicing\\EconomicCustomersListResponseValidationTest::__pest_evaluable_it_throws_when_pagination_is_missing_from_the_response_payload":0,"P\\Tests\\Unit\\Invoicing\\EconomicCustomersListResponseValidationTest::__pest_evaluable_it_throws_when_upstream_responds_with_an_error_shaped_payload":0,"P\\Tests\\Unit\\Router\\RouterThrowableHandlingTest::__pest_evaluable_it_catches_throwables_during_auto_route_loading_and_maps_them_to_internal_server_errors":0.003,"P\\Tests\\Unit\\Users\\EconomicCustomerZeroHandlingTest::__pest_evaluable_it_does_not_request_e_conomic_customer_data_when_customer_number_is_zero":0.101,"P\\Tests\\Unit\\Users\\EconomicCustomerZeroHandlingTest::__pest_evaluable_it_short_circuits_e_conomic_customer_lookup_for_non_positive_customer_numbers":0.005,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueRouteRegistrationTest::__pest_evaluable_it_registers_queued_economic_invoice_endpoints_in_economicInvoiceRoute":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueRouteRegistrationTest::__pest_evaluable_it_registers_collected_invoice_queue_endpoints_in_orderInvoicesRoute":0.005,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueSchemaBootstrapTest::__pest_evaluable_it_defines_economic_transfer_queue_jobs_schema_bootstrap_table_and_tracking_columns":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueSchemaBootstrapTest::__pest_evaluable_it_provides_queue_processor_class_constants_and_processing_entrypoint":0.003,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueCronIntegrationTest::__pest_evaluable_it_registers_economic_transfer_queue_cron_task_and_handler":0.005,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_economic_transfer_queue_paths_in_openapi":0.01,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_economic_transfer_queue_schemas_in_openapi":0.007,"P\\Tests\\Unit\\Invoicing\\EconomicTransferOrderItemSkipTest::__pest_evaluable_it_skips_order_items_with_zero_quantity_for_e_conomic_export":0.008,"P\\Tests\\Unit\\Invoicing\\EconomicTransferOrderItemSkipTest::__pest_evaluable_it_skips_order_items_with_zero_price_for_e_conomic_export":0,"P\\Tests\\Unit\\Invoicing\\EconomicTransferOrderItemSkipTest::__pest_evaluable_it_keeps_positive_quantity_and_price_items_billable_for_e_conomic_export":0,"P\\Tests\\Unit\\Invoicing\\EconomicTransferOrderItemSkipTest::__pest_evaluable_it_wires_zero_cost_and_zero_quantity_skip_guard_into_transfer_line_builder":0.003,"P\\Tests\\Unit\\Invoicing\\EconomicInvoiceDraftZeroItemSkipWiringTest::__pest_evaluable_it_skips_zero_cost_and_zero_quantity_items_in_legacy_economic_draft_helper":0.008,"P\\Tests\\Unit\\Invoicing\\EconomicTransferOrderItemSkipTest::__pest_evaluable_it_skips_malformed_order_item_payloads_for_e_conomic_export_safety":0,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueHardeningTest::__pest_evaluable_it_hardens_transfer_queue_with_type_validation_retry_caps_and_stale_lock_recovery":0.004,"P\\Tests\\Unit\\Router\\AutoloadRedisCacheValidationTest::__pest_evaluable_it_validates_cached_autoload_paths_before_returning_and_clears_stale_cache_entries":0.003,"P\\Tests\\Unit\\Orders\\OrdersRegistrationDateRangeQueryTest::__pest_evaluable_it_applies_created__at_bounds_directly_in_SQL_when_filtering_orders_by_registration_number":0,"P\\Tests\\Unit\\Orders\\OrdersRegistrationDateRangeQueryTest::__pest_evaluable_it_rejects_an_inverted_date_range_for_registration_lookups":0,"P\\Tests\\Unit\\Orders\\OrdersRegistrationDateRangeQueryTest::__pest_evaluable_it_returns_early_when_registration_number_is_blank":0,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueCronIntegrationTest::__pest_evaluable_it_wires_queue_worker_class_loading_for_CLI_queue_action":0.003,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_guards_all_economic_invoice_queue_endpoints_before_constructing_queue_service":0.012,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_guards_all_collected_invoice_queue_endpoints_before_constructing_queue_service":0.011,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_uses_a_consistent_unavailable_service_contract_for_missing_queue_dependencies":0.009,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_gracefully_handles_unavailable_queue_dependencies_on_collected_invoice_queue_endpoints":0.005,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceEconomicQueueResponseContractTest::__pest_evaluable_it_returns_the_queue_job_id_in_POST__collected_invoices_economic_enqueue_response":0.204,"P\\Tests\\Unit\\Invoicing\\UserCollectedInvoiceUpdateRouteValidationTest::__pest_evaluable_it_requires_id_and_at_least_one_mutable_field_for_PUT__collected_invoices_in_user_route":0.004,"P\\Tests\\Unit\\Invoicing\\UserCollectedInvoiceUpdateRouteValidationTest::__pest_evaluable_it_supports_independent_po__number_and_closed__at_updates_for_PUT__collected_invoices_in_user_route":0.175,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceEconomicQueueResponseContractTest::__pest_evaluable_it_returns_queued_contract_for_POST__collected_invoices_stripe_book":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicDraftQueueOnlyRouteBehaviorTest::__pest_evaluable_it_keeps_order_draft_export_route_queue_only":0.006,"P\\Tests\\Unit\\Invoicing\\EconomicDraftQueueOnlyRouteBehaviorTest::__pest_evaluable_it_keeps_collected_invoice_draft_producing_routes_queue_only_in_orderInvoicesRoute":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_guards_collected_invoice_and_stripe_draft_producing_endpoints_before_constructing_queue_service":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_unavailable_queue_dependency_responses_for_async_economic_transfer_endpoints":0.007,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueuePayloadValidationTest::__pest_evaluable_it_normalizes_order_payloads_to_strict_positive_integer_ids":0,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueuePayloadValidationTest::__pest_evaluable_it_normalizes_collected_invoice_payload_booleans_to_strict_bool_values":0.007,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueuePayloadValidationTest::__pest_evaluable_it_rejects_invalid_transfer_payloads_before_enqueue_write_attempts":0.036,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_processes_queued_transfer_jobs_to_completion":0.284,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_deduplicates_active_jobs_per_transfer_target":0.012,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_rejects_invalid_payloads_without_inserting_queue_rows":0.011,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_supports_fail_retry_and_reprocess_lifecycle_transitions":0.006,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_keeps_queue_guards_on_economic_invoice_queue_status_and_retry_routes":0.012,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_supports_synchronous_fallback_branches_before_queue_enqueue_on_economic_invoice_export_routes":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_keeps_queue_status_endpoints_guarded_while_collected_invoice_export_routes_support_synchronous_fallback":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_uses_a_consistent_unavailable_service_contract_for_queue_status_lifecycle_endpoints":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicDraftQueueOnlyRouteBehaviorTest::__pest_evaluable_it_supports_synchronous_fallback_and_queued_processing_for_order_draft_export_route":0.005,"P\\Tests\\Unit\\Invoicing\\EconomicDraftQueueOnlyRouteBehaviorTest::__pest_evaluable_it_supports_synchronous_fallback_and_queued_processing_for_collected_invoice_draft_producing_routes":0.004,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceEconomicQueueResponseContractTest::__pest_evaluable_it_documents_both_synchronous_fallback_and_queued_response_contracts_for_POST__collected_invoices_economic":0.236,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceEconomicQueueResponseContractTest::__pest_evaluable_it_documents_both_synchronous_fallback_and_queued_response_contracts_for_POST__collected_invoices_stripe_book":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_200_fallback_plus_202_queue_contracts_for_export_endpoints_and_keeps_503_on_queue_lifecycle_endpoints":0.009,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueRouteHardeningTest::__pest_evaluable_it_returns_pagination_metadata_and_strict_status_handling_for_collected_invoice_queue_list":0.014,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueRouteHardeningTest::__pest_evaluable_it_enforces_retry_constraints_for_collected_invoice_queue_jobs_before_retry_execution":0.005,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_collected_queue_list_metadata_and_strict_status_filter_enum_in_openapi":0,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_builds_a_completed_collected_invoice_queue_summary_from_payload_and_result":0.195,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_prefers_queue_error_messages_for_failed_jobs_and_keeps_null_safe_outcome_fields":0.003,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_uses_deterministic_status_message_fallback_when_no_explicit_message_exists":0.003,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_handles_object_payload_result_values_and_malformed_fields_without_throwing":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueWorkerTickRouteTest::__pest_evaluable_it_ticks_collected_invoice_transfer_queue_from_queue_list_and_status_routes":0.008,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueWorkerTickRouteTest::__pest_evaluable_it_ticks_order_transfer_queue_from_draft_and_invoice_status_routes":0.009,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodQueueOverlayTest::__pest_evaluable_it_marks_queued_transactions_and_clears_requires__action_when_all_actionable_work_is_already_queued":0.826,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodQueueOverlayTest::__pest_evaluable_it_keeps_requires__action_true_when_a_customer_still_has_unqueued_actionable_transactions":0.045,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodQueueOverlayTest::__pest_evaluable_it_blocks_fixed_pricing_or_subscription_customers_with_no_transactions_when_a_relevant_queue_job_exists":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerCommandQueueTest::__pest_evaluable_it_queues_admin_commands__exposes_agent_poll_result_handlers__and_keeps_heartbeats_from_mutating_discovery_state":0.003,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerCommandQueueTest::__pest_evaluable_it_defines_the_dispatchable_gateway_guard_on_the_loaded_manager_class":0.009,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_effective_gateway_status_from_heartbeat_freshness with data set \"dataset \"recent heartbeat stays online\"\"":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_effective_gateway_status_from_heartbeat_freshness with data set \"dataset \"late heartbeat degrades after one minute\"\"":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_effective_gateway_status_from_heartbeat_freshness with data set \"dataset \"stale heartbeat goes offline after five minutes\"\"":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_effective_gateway_status_from_heartbeat_freshness with data set \"dataset \"explicit offline reports stay offline even when heartbeat is fresh\"\"":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_effective_gateway_status_from_heartbeat_freshness with data set \"dataset \"missing heartbeat is treated as offline\"\"":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_marks_ready_discovery_as_stale_when_the_gateway_heartbeat_has_expired":0.351,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayRouteWiringTest::__pest_evaluable_it_registers_the_edge_gateway_management_REST_endpoints":0.021,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayRouteWiringTest::__pest_evaluable_it_registers_public_installer__claim__heartbeat__command_polling__and_internal_broker_auth_endpoints":0.004,"P\\Tests\\Unit\\Selfserve\\EdgeBrokerClientConfigTest::__pest_evaluable_it_uses_the_same_default_broker_url_and_shared_secret_fallback_as_the_docker_stack":0.267,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_install_script_urls_with_forwarded_https_scheme_when_proxied":0.336,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_appends_forwarded_ports_when_the_forwarded_host_omits_them":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_infers_https_for_the_staging_api_host_when_only_the_https_port_is_present":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_prefers_EDGE__PUBLIC__API__URL_when_explicitly_configured":0.168,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_normalizes_public_broker_overrides_to_https_and_browser_shell_urls_to_wss":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_keeps_localhost_broker_overrides_on_plain_http_and_ws_for_local_development":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_includes_node_pty_build_prerequisites_in_the_generated_install_script":0.007,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_prefers_EDGE__PUBLIC__API__URL_when_explicitly_configured_and_derives_the_broker_from_the_api_origin":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_normalizes_public_broker_overrides_to_https__strips_paths__and_browser_shell_urls_to_wss":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_merges_incoming_heartbeat_metadata_with_existing_gateway_metadata":0.006,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayRouteWiringTest::__pest_evaluable_it_registers_the_v2_operator_facing_edge_gateway_routes":0.004,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayRouteWiringTest::__pest_evaluable_it_registers_PHP_edge_agent_routes_for_operations_and_legacy_relay_command_polling":0.005,"P\\Tests\\Unit\\Selfserve\\EdgeGatewaySchemaBootstrapTest::__pest_evaluable_it_defines_the_v2_edge_gateway_schema_bootstrap_tables":0.004,"P\\Tests\\Unit\\Selfserve\\EdgeGatewaySchemaBootstrapTest::__pest_evaluable_it_stores_operation_metadata_and_event_timelines_for_management_workflows":0.004,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_builds_the_installer_around_the_PHP_agent_artifacts_and_management_polling_config":0.011,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_exposes_update_payload__credential_rotation__and_operation_endpoints_without_shell_transport_wiring":0.013,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerCommandQueueTest::__pest_evaluable_it_keeps_relay_dispatch_and_discovery_queueing_on_the_edge_gateway_manager":0.08,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerCommandQueueTest::__pest_evaluable_it_loads_relay_command_helpers_on_the_manager_and_gateway_operations_on_the_dedicated_service":0.014,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_relay_fallback_and_transport_health_details_without_shell_or_update_runtime_state":0.364,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_resolves_edge_agent_artifacts_from_the_source_tree_layout":0.445,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_includes_the_container_mounted_artifact_directory_as_a_candidate":0.021,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_reads_install_artifacts_through_the_shared_locator":0.008,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_builds_update_payloads_with_checksums_from_resolved_artifact_paths":0.427,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_resolves_edge_agent_artifacts_from_a_supported_runtime_layout":0.017,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_install_script_urls_with_the_PHP_agent_artifacts_and_forwarded_https_scheme":0.001,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_explains_the_legacy_dist_mount_mismatch_when_php_artifacts_are_unavailable":0.001,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_prioritizes_mounted_and_baked_in_artifact_directories_before_repo_fallbacks":0.022,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_falls_back_to_baked_in_artifacts_when_the_mount_path_is_absent":0.001,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_prioritizes_router_resources_before_mounted_and_baked_in_artifact_directories":0.022,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_lists_orders_for_an_admin_scoped_user_and_limits_the_results_to_the_permitted_departments":1.109,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_lists_only_the_targeted_customer_orders_for_subuser_sessions":2.935,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_returns_the_current_auth_and_permission_failures_when_order_listing_is_not_allowed":1.375,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_creates_orders_through_the_real_endpoint":0.982,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_order_creation_requests":3.423,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_updates_orders_through_the_primary_endpoint":1.13,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_reassigns_invoice_collections_when_changing_an_order_across_the_draft_customer_boundary":1.615,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_supports_legacy_field_value_metadata_updates_through_the_primary_endpoint":5.592,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_updates_through_the_primary_order_endpoint":1.29,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_updates_orders_through_the_legacy_alias_endpoint":1.517,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_supports_legacy_field_value_metadata_updates_through_the_alias_endpoint":4.136,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_updates_through_the_legacy_alias_endpoint":0.512,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_unsupported_legacy_field_value_updates_on_both_update_endpoints":0.965,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_deletes_orders_through_the_real_endpoint":1.045,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_order_delete_requests":1.64,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_regenerates_attached_wash_certificates_when_certificate_metadata_changes_through_the_primary_endpoint":3.683,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_regenerates_attached_wash_certificates_for_legacy_field_value_updates_through_the_alias_endpoint":3.194,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayOperationLegacySchemaCompatibilityTest::__pest_evaluable_it_keeps_the_legacy_operation__type_column_compatible_with_v2_operation_queueing":0.015,"P\\Tests\\Unit\\Bookings\\OrderBookingsListCacheTest::__pest_evaluable_it_uses_sane_ttl_defaults_and_clamps_negative_ttl_to_zero":0,"P\\Tests\\Unit\\Bookings\\OrderBookingsListCacheTest::__pest_evaluable_it_builds_deterministic_keys_for_equivalent_contexts":0,"P\\Tests\\Unit\\Bookings\\OrderBookingsListCacheTest::__pest_evaluable_it_stores_and_retrieves_full_order_bookings_payloads":0,"P\\Tests\\Unit\\Bookings\\OrderBookingsListCacheTest::__pest_evaluable_it_ignores_malformed_cached_payloads_and_clears_order_bookings_list_caches":0,"P\\Tests\\Unit\\Bookings\\OrderBookingsRouteCacheWiringTest::__pest_evaluable_it_caches_the_paginated_order_bookings_list_response_and_invalidates_it_on_booking_changes":0.008,"P\\Tests\\Unit\\Bookings\\VehicleSearchBookedMetadataRouteContractTest::__pest_evaluable_it_keeps_booked_vehicle_search_metadata_aligned_with_filtered_pending_order_bookings":0.005,"P\\Tests\\Unit\\Bookings\\OrderBookingsCountsCacheTest::__pest_evaluable_it_uses_sane_ttl_defaults_and_clamps_negative_ttl_to_zero":0.012,"P\\Tests\\Unit\\Bookings\\OrderBookingsCountsCacheTest::__pest_evaluable_it_builds_deterministic_keys_for_equivalent_count_contexts":0.012,"P\\Tests\\Unit\\Bookings\\OrderBookingsCountsCacheTest::__pest_evaluable_it_stores_and_retrieves_normalized_booking_counts":0.014,"P\\Tests\\Unit\\Bookings\\OrderBookingsCountsCacheTest::__pest_evaluable_it_ignores_malformed_payloads_and_clears_order_bookings_count_caches":0.078,"P\\Tests\\Unit\\Bookings\\OrderBookingsCountsRouteWiringTest::__pest_evaluable_it_adds_a_cached_order_bookings_counts_endpoint_and_invalidates_it_on_booking_changes":0.008,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayFleetUsageStatisticsTest::__pest_evaluable_it_summarizes_fleet_usage_statistics_for_the_dashboard_landing_view":0,"P\\Tests\\Api\\ApiCoverageManifestTest::__pest_evaluable_it_keeps_every_selected_API_operation_covered_by_happy_path_and_failure_tests":0.271,"P\\Tests\\Api\\ApiCoverageManifestTest::__pest_evaluable_it_keeps_the_OpenAPI_manifest_entries_aligned_with_the_API_spec":0.097,"P\\Tests\\Api\\ApiFixturesCleanupTest::__pest_evaluable_it_removes_generated_customer_traces_during_fixture_cleanup":8.099,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_logs_in_with_valid_customer_credentials":4.515,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_rejects_invalid_login_payloads_and_credentials":1.271,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_returns_the_cached_auth_session_payload_for_a_valid_token":0.888,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_includes_economic_runtime_config_for_uncached_auth_sessions":1.349,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_rejects_invalid_auth_session_tokens":0.698,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_logs_out_and_invalidates_the_token_for_future_session_calls":1.365,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_logs_out_and_invalidates_cached_subuser_sessions":1.932,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_rejects_invalid_logout_tokens":0.393,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_returns_the_raw_202_gather_webhook_contract_over_HTTP":0.788,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_still_prompts_for_department_selection_when_only_one_department_is_eligible":0.732,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_accepts_the_legacy_initial_webhook_body_with_top_level_call_identifiers":0.692,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_returns_native_flow_gather_data_after_a_top_level_department_selection_when_distinct_gate_choices_exist":1.515,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_opens_the_gate_immediately_after_a_top_level_department_selection_when_entrance_and_exit_share_the_same_gate":1.719,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_uses_a_multi_digit_gather_contract_when_10_departments_are_eligible":0.587,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_returns_a_raw_400_transport_error_for_malformed_webhook_payloads":0.419,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_lists_only_visible_departments_and_can_return_a_single_department_with_the_slack_webhook":1.048,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_department_listing_when_the_permission_is_missing":0.378,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_creates_departments_through_the_real_endpoint":0.59,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_invalid_department_create_requests":1.554,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_updates_departments_through_the_real_endpoint":0.788,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_invalid_department_update_requests":1.552,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_lists_department_categories_for_a_department":1.006,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_invalid_department_category_requests":1.191,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_lists_the_draft_customer_config_entry_in_economic_config_responses":0.947,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_round_trips_the_draft_customer_config_value_through_economic_config_updates":3.28,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_rejects_order_draft_exports_for_the_configured_draft_customer":0.86,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_rejects_booked_invoice_exports_for_the_configured_draft_customer":1.127,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_rejects_collected_invoice_exports_for_the_configured_draft_customer":0.888,"P\\Tests\\Api\\PingApiTest::__pest_evaluable_it_returns_the_ping_contract":0.877,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_returns_a_setup_required_error_when_department_terminal_readers_are_requested_without_terminal_setup":1.281,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_sends_a_Stripe_invoice_by_email_and_persists_the_hosted_invoice_association":5.434,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_returns_a_conflict_when_a_Stripe_hosted_invoice_is_already_active_for_the_order":4.756,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_allows_sending_a_new_Stripe_hosted_invoice_when_the_existing_association_is_already_terminal":3.943,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_voids_an_unpaid_Stripe_hosted_invoice_and_clears_the_local_order_association":3.746,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_refuses_to_cancel_a_paid_Stripe_hosted_invoice":4.131,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_returns_a_setup_required_error_when_creating_a_payment_intent_for_a_department_without_terminal_setup":0.726,"P\\Tests\\Api\\VehiclesApiTest::__pest_evaluable_it_returns_the_newest_vehicle_last__order__id_that_still_has_order_items":1.326,"P\\Tests\\Api\\VehiclesApiTest::__pest_evaluable_it_returns_a_null_vehicle_last__order__id_when_no_order_with_items_exists":1.159,"P\\Tests\\Unit\\Users\\EconomicCustomerModelParsingTest::__pest_evaluable_it_parses_cached_economic_customer_payloads_that_use_snake__case_customer__number":0.246,"P\\Tests\\Unit\\Users\\EconomicCustomerModelParsingTest::__pest_evaluable_it_leaves_the_economic_customer_model_empty_when_no_valid_customer_number_is_present":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayFleetUsageStatisticsTest::__pest_evaluable_it_derives_fleet_usage_directly_from_cached_gateway_row_summaries":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_install_script_urls_with_the_compose_edge_gateway_artifacts_and_forwarded_https_scheme":0.182,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayMetadataUpdateContractTest::__pest_evaluable_it_adds_a_gateway_metadata_update_endpoint_with_label_and_primary_assignment_fields":0.01,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayMetadataUpdateContractTest::__pest_evaluable_it_reassigns_department_primary_gateways_through_dedicated_manager_helpers":0.007,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayShellPollingTransportTest::__pest_evaluable_it_removes_shell_access_from_the_edge_gateway_HTTP_contracts":0.005,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_builds_the_installer_around_the_compose_stack_artifacts_and_management_polling_config":0.053,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayViewCacheTest::__pest_evaluable_it_uses_sane_ttl_defaults_and_deterministic_cache_keys":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayViewCacheTest::__pest_evaluable_it_stores_and_retrieves_cached_list_and_detail_payloads":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayViewCacheTest::__pest_evaluable_it_syncs_gateway_snapshots_into_cached_list_payloads_and_refreshes_fleet_usage":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayViewCacheTest::__pest_evaluable_it_removes_deleted_gateways_from_cached_list_and_detail_payloads":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_exposes_update_payload__credential_rotation__cancel_endpoints__and_operation_endpoints_without_shell_transport_wiring":0.012,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayModuleRouteWiringTest::__pest_evaluable_it_registers_module_scoped_edge_gateway_operator_routes":0.007,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayModuleRouteWiringTest::__pest_evaluable_it_registers_edge_gateway_module_config_endpoints":0.006,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayModuleRouteWiringTest::__pest_evaluable_it_registers_edge_gateway_config_endpoints_from_the_module_route_directory":0.011,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayModuleRouteWiringTest::__pest_evaluable_it_keeps_only_the_module_facade_in_the_global_classes_directory_and_conditionally_loads_module_routes":0.005,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayInstallSessionLifecycleTest::__pest_evaluable_it_caps_install_session_diagnostics_and_events_while_preserving_the_first_start_timestamp":0.026,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayInstallSessionLifecycleTest::__pest_evaluable_it_clears_terminal_failure_details_after_a_successful_claim_update":0.014,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayInstallSessionLifecycleTest::__pest_evaluable_it_marks_expired_non_terminal_install_sessions_as_terminal_when_read_back":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_websocket_broker_urls_on_the_traefik_broker_path":0,"P\\Tests\\Unit\\Selfserve\\DepartmentGateConfigRelayTest::__pest_evaluable_it_validates_relay_gate_configs_and_keeps_relay_specific_fields_in_the_payload":0.025,"P\\Tests\\Unit\\Selfserve\\DepartmentGateConfigRelayTest::__pest_evaluable_it_rejects_relay_gate_configs_without_a_logical_relay_id":0.014,"P\\Tests\\Unit\\Bird\\DepartmentGatesRelayOpenTest::__pest_evaluable_it_dispatches_relay_backed_gates_through_the_edge_gateway_relay_manager":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayDepartmentWorkspaceContractTest::__pest_evaluable_it_defines_the_department_hardware_workspace_service_payload_surface":0.011,"P\\Tests\\Unit\\Selfserve\\PlateScannerWorkspaceContractTest::__pest_evaluable_it_adds_lane_aware_scanner_management_and_a_dedicated_rotate_key_action":0.01,"P\\Tests\\Unit\\Selfserve\\PlateScannerWorkspaceContractTest::__pest_evaluable_it_uses_the_scanner_default_lane_before_requiring_an_explicit_lane__id_in_machine_button_webhooks":0.005,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_maps_gateway_relay_status_responses_into_the_Shelly_cloud_payload_shape":0.01,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_maps_gateway_relay_switch_responses_into_the_Shelly_cloud_payload_shape":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_requires_a_valid_department_id_for_gateway_transport_requests":0.009,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_rejects_unsupported_gateway_transport_endpoints":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_surfaces_binding_lookup_failures_while_resolving_relay_state_through_the_gateway_transport":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_surfaces_offline_gateway_failures_while_dispatching_relay_switch_commands":0,"P\\Tests\\Api\\PlateScannersApiTest::__pest_evaluable_it_returns_the_updated_lane_id_after_editing_a_scanner_whose_null_lane_was_already_cached":21.835,"P\\Tests\\Api\\PlateScannersApiTest::__pest_evaluable_it_rejects_plate_scanner_edits_when_the_permission_is_missing":7.837,"P\\Tests\\Unit\\Selfserve\\ShellyRelayInventoryTest::__pest_evaluable_it_normalizes_owned_Shelly_devices_into_relay_select_options":0.102,"P\\Tests\\Unit\\Selfserve\\ShellyRelayInventoryTest::__pest_evaluable_it_fails_fast_when_Shelly_inventory_does_not_include_owned_devices_status":0.011,"P\\Tests\\Unit\\Selfserve\\DepartmentLaneRelayOptionsRouteWiringTest::__pest_evaluable_it_registers_a_department_lane_relay_options_endpoint_backed_by_Shelly_inventory":0.003,"P\\Tests\\Unit\\Selfserve\\DepartmentLaneRelayNullificationRouteWiringTest::__pest_evaluable_it_nullifies_department_lane_relay_fields_when_blank_select_values_are_submitted":0.004,"P\\Tests\\Api\\EdgeGatewayAgentApiTest::__pest_evaluable_it_serves_installer_artifacts_and_recovers_gateway_runtime_status_after_fresh_heartbeats":109.487,"P\\Tests\\Api\\EdgeGatewayAgentApiTest::__pest_evaluable_it_polls_operations_and_commands__submits_results__and_records_broker_presence_for_task_pages":229.768,"P\\Tests\\Api\\EdgeGatewayAgentApiTest::__pest_evaluable_it_rejects_missing_and_invalid_edge_agent_tokens":12.815,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_validates_broker_sessions_and_ingests_presence__telemetry__logs__and_shell_lifecycle_data":159.38,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_builds_broker_backlog_and_completes_gateway_operations_through_broker_endpoints":183.535,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_rejects_invalid_edge_broker_shared_secrets":8.587,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_creates_install_tokens__tracks_installer_status__and_exposes_claimed_gateway_detail_to_authorized_operators":134.744,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_manages_edge_gateway_metadata__bindings__operations__sessions__rotation__cutover__and_deletion":272.219,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_rejects_operator_edge_routes_when_module_permission_or_department_access_is_missing":46.681,"P\\Tests\\Integration\\EdgeGateway\\EdgeGatewayBackendIntegrationTest::__pest_evaluable_it_persists_install_session_updates_and_derives_gateway_runtime_status_from_heartbeats":31.971,"P\\Tests\\Integration\\EdgeGateway\\EdgeGatewayBackendIntegrationTest::__pest_evaluable_it_assembles_tasks__logs__statistics__operations__commands__and_shell_lifecycle_state_from_persisted_records":167.91,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_localhost_websocket_broker_urls_on_the_local_traefik_api_prefix":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_keeps_root_host_api_urls_unprefixed_when_the_request_is_not_under_the_local_api_alias":0,"P\\Tests\\Unit\\Customers\\CustomerMassImportServiceTest::__pest_evaluable_it_imports_a_matching_e_conomic_customer_into_the_local_system_when_no_local_record_exists":0.282,"P\\Tests\\Unit\\Customers\\CustomerMassImportServiceTest::__pest_evaluable_it_reports_when_the_local_customer_already_has_a_login_account":0,"P\\Tests\\Unit\\Customers\\CustomerMassImportServiceTest::__pest_evaluable_it_creates_a_new_e_conomic_customer_and_returns_a_created_result_for_new_rows":0.012,"P\\Tests\\Unit\\Customers\\CustomerMassImportServiceTest::__pest_evaluable_it_creates_the_economic_record_for_an_existing_local_account_when_no_matching_upstream_customer_exists":0,"P\\Tests\\Unit\\Customers\\CustomerMassImportServiceTest::__pest_evaluable_it_rejects_CVR_conflicts_when_the_upstream_customer_number_does_not_match_the_submitted_phone_number":0.02,"P\\Tests\\Unit\\Customers\\CustomerMassImportServiceTest::__pest_evaluable_it_registers_the_customer_import_route_and_wires_it_through_the_mass_import_service":0.099,"P\\Tests\\Unit\\Users\\UsersCustomerNamesCacheTest::__pest_evaluable_it_builds_customer_name_cache_payloads_from_economic_data_or_display_name_fallbacks":0,"P\\Tests\\Unit\\Users\\UsersCustomerNamesCacheTest::__pest_evaluable_it_guards_bulk_customer_name_cache_writes_behind_a_resolved_payload_check":0.001,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_ignores_and_soft_deletes_edge_gateways_whose_department_no_longer_exists":35.946,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_only_trusts_broker_presence_while_the_broker_heartbeat_is_fresh":0.68,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_refreshes_broker_presence_from_broker_telemetry_heartbeats":0.005,"P\\Tests\\Unit\\Selfserve\\ShellyTransportResolverTest::__pest_evaluable_it_resolves_the_injected_gateway_transport_when_a_department_is_in_gateway_mode":0,"P\\Tests\\Unit\\Selfserve\\ShellyTransportResolverTest::__pest_evaluable_it_resolves_the_injected_cloud_transport_when_a_department_is_in_cloud_mode":0,"P\\Tests\\Unit\\Selfserve\\ShellyTransportResolverTest::__pest_evaluable_it_lets_relay_tests_override_the_department_transport_without_changing_department_mode":0,"P\\Tests\\Unit\\Selfserve\\ShellyRelayInventoryTest::__pest_evaluable_it_falls_back_to_local_device_and_control_names_when_Shelly_cloud_list_metadata_is_unavailable":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_uses_local_only_gateway_dispatch_for_explicit_local_transport_overrides":0,"P\\Tests\\Unit\\Selfserve\\ShellyTransportResolverTest::__pest_evaluable_it_marks_non_injected_local_overrides_as_local_only_gateway_transport":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_opens_exit_port_by_switching_relay__out__id_on":0.224,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_opens_entrance_port_by_switching_relay__in__id_on":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_forwards_Shelly_toggle__after_timers_to_gateway_relay_switches":0,"P\\Tests\\Unit\\Selfserve\\ShellyTransportResolverTest::__pest_evaluable_it_marks_non_injected_local_and_gateway_overrides_as_local_only_gateway_transport":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_preserves_local_gateway_diagnostic_metadata_on_relay_status_snapshots":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_includes_positive_relay_timers_in_Shelly_switch_payloads":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_applies_Shelly_transport_overrides_across_self_serve_relay_side_effect_routes":0.049,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayLegacyRouteShimTest::__pest_evaluable_it_keeps_guarded_legacy_root_shims_for_moved_edge_gateway_routes":0.014,"P\\Tests\\Integration\\EdgeGateway\\EdgeGatewayBackendIntegrationTest::__pest_evaluable_it_persists_gateway_cutover_relay_bindings_used_by_self_serve_Shelly_dispatch":18.006,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_passes_explicit_timer_values_when_opening_lane_gates":0,"P\\Tests\\Unit\\Selfserve\\ShellyRelayInventoryTest::__pest_evaluable_it_keeps_Shelly_1_Mini_Gen3_type__model__and_generation_aligned_with_Shelly_metadata":0,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_rejects_shell_session_creation_while_broker_presence_is_unavailable":20.8}} \ No newline at end of file +{"version":"pest_3.8.6","defects":{"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_self_serve_machine_type__eligibility__summary__and_webhook_endpoints":1,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_defines_reusable_self_serve_wash_and_machine_type_schemas":1,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_redistributes_booked_department_75_net_amounts_using_fixed_pricing_and_wash_subscription_weights":7,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_keeps_classified_booked_department_75_amounts_undistributed_when_no_monthly_basis_exists":8,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_keeps_unclassified_booked_department_75_lines_undistributed_with_warnings":8,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_documents_v2_historical_distribution_and_pricing_history_paths_in_openapi":1,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_defines_new_reusable_v2_schemas_for_normalization_comparison_versioning_and_distribution":1,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_supports_bulk_booked_invoice_line_payloads_with_top_level_department_numbers":7,"P\\Tests\\Unit\\Redis\\RedisAtomicReservationTest::__pest_evaluable_it_claims_a_slot_atomically_with_nx_and_expiration":8,"P\\Tests\\Unit\\Redis\\RedisAtomicReservationTest::__pest_evaluable_it_returns_false_when_the_slot_is_already_claimed_and_clamps_ttl_to_one_second":8,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_documents_economic_v2_invoice_paths_in_openapi":1,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_aligns_legacy_compare_schema_with_runtime_payload_by_removing_stale_required_order__ids":1,"P\\Tests\\Unit\\Search\\SystemSearchEntityTypeCoverageTest::__pest_evaluable_it_documents_every_supported_search_entity_type_in_openapi_enum":1,"P\\Tests\\Unit\\Search\\SystemSearchOpenApiSpecTest::__pest_evaluable_it_documents_system_wide_search_endpoints_in_openapi":1,"P\\Tests\\Unit\\Search\\SystemSearchOpenApiSpecTest::__pest_evaluable_it_documents_debug__intent_and_parser_metadata_schema_in_openapi":1,"P\\Tests\\Unit\\Search\\SystemSearchOpenApiSpecTest::__pest_evaluable_it_documents_e_conomic_indexed_customer_matching_and_synonym_behavior":1,"P\\Tests\\Integration\\Database\\DbConnectionTest::__pest_evaluable_it_can_connect_to_a_configured_MySQL_instance_in_integration_mode":1,"P\\Tests\\Integration\\Invoicing\\EconomicV2BackfillAndDistributionIntegrationTest::__pest_evaluable_it_runs_best_effort_backfill_repeatedly_without_introducing_duplicate_same_start_rows":1,"P\\Tests\\Integration\\Invoicing\\EconomicV2BackfillAndDistributionIntegrationTest::__pest_evaluable_it_resolves_version_aware_distribution_payload_shapes_over_a_real_date_range":1,"P\\Tests\\Integration\\Invoicing\\EconomicV2VersioningServiceIntegrationTest::__pest_evaluable_it_creates_closes_and_rotates_fixed_pricing_versions_without_overlap":1,"P\\Tests\\Integration\\Invoicing\\EconomicV2VersioningServiceIntegrationTest::__pest_evaluable_it_tracks_vehicle_and_discount_version_lifecycles_with_closure_semantics":1,"P\\Tests\\Integration\\Permissions\\LegacyPermissionRedisCacheScriptTest::__pest_evaluable_it_keeps_legacy_route_permission_redis_cache_script_green":1,"P\\Tests\\Unit\\Goals\\GoalsOpenApiSpecTest::__pest_evaluable_it_documents_advanced_goals_target_duration_fields_in_openapi":1,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineSchemaConformanceTest::__pest_evaluable_it_documents_the_date_key_in_the_openapi_department_weather_timeline_schema":1,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_builds_timeline_entries_with_mostly__clear_weather_when_forecast_payload_is_empty":8,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_wires_departments_weather_route_through_the_forecast_fallback_path":7,"P\\Tests\\Unit\\Orders\\AttachmentsListManyTest::__pest_evaluable_it_groups_attachments_by_object_id_and_preserves_empty_object_groups":8,"P\\Tests\\Unit\\Orders\\AttachmentsListManyTest::__pest_evaluable_it_keeps_listMany_payload_parity_with_list_for_one_object_id":8,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsOpenApiSpecTest::__pest_evaluable_it_documents_department_weather_target_endpoints_and_reusable_schemas_in_openapi":1,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsOpenApiSpecTest::__pest_evaluable_it_documents_unknown_weather_status_behavior_when_targets_are_missing":1,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_respects_the_1_request_per_second_Shelly_gate_for_back_to_back_requests":8,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_in_progress_self_serve_wash_start_and_machine_relay_fields":1,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_stores_config_json_with_apostrophes_without_violating_json_constraints":8,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_adds_vehicle_type_product_on_STOP_when_program_selector_is_on__then_turns_off_cleaner_machine_and_selector_relays":7,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_skips_vehicle_type_product_add_when_program_selector_is_off_and_only_disables_configured_relays":7,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_self_serve_machine_wash_included_minutes_in_config_schemas":1,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_fast_when_workspace_id_is_missing_for_gate_calls":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_fast_when_channel_id_is_missing_for_gate_calls":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_with_terminal_status_details_when_call_never_reaches_accepted_state":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_hangs_up_when_call_reaches_accepted_state":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_hangs_up_immediately_when_status_transitions_to_accepted":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_maps_legacy_timeout_option_to_documented_ringTimeout_payload_field":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_clamps_derived_ringTimeout_to_Bird_documented_max_when_gate_timeout_is_high":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_passes_documented_hangup_cause_when_provided":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_drops_unsupported_hangup_cause_values_from_request_payload":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_fast_when_workspace_id_is_missing_for_gate_flash_calls":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_creates_gate_flash_call_with_documented_ringTimeout_payload":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_polls_flash_call_and_succeeds_once_accepted":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_flash_gate_flow_when_terminal_failure_status_is_returned":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_does_not_fallback_to_regular_gate_call_when_flash_succeeds":8,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_falls_back_to_regular_gate_call_when_flash_fails":8,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_property_gate_lane_commands_and_sanitized_gate_failure_responses":1,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_returns_raw_payload_for_malformed_JSON_responses_and_null_for_empty_responses":8,"P\\Tests\\Unit\\Bird\\BirdRouteWiringTest::__pest_evaluable_it_keeps_Bird_number_and_webhook_routes_intact":7,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_handles_malformed_and_empty_response_bodies_without_throwing_transport_errors":8,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_aggregates_multi_department_slot_statuses_using_worst_severity_and_unknown_fallback_rules":7,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_adds_vehicle_type_product_on_STOP_when_program_selector_relay_is_online__then_turns_off_cleaner_and_machine_relays":7,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_bills_primary_product_when_selector_relay_is_online_even_if_relay_output_is_off":7,"P\\Tests\\Unit\\Bird\\BirdOpenApiSpecTest::__pest_evaluable_it_documents_all_Bird_voice_call_parity_endpoints_including_gather_recordings_insights_and_log":1,"P\\Tests\\Unit\\Bird\\BirdOpenApiSpecTest::__pest_evaluable_it_documents_flash_hangup_endpoint_and_marks_end_alias_as_deprecated":1,"P\\Tests\\Unit\\Bird\\BirdOpenApiSpecTest::__pest_evaluable_it_defines_request_and_response_schemas_for_Bird_call_command_recording_insight_log_and_flash_payloads":1,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueRouteRegistrationTest::__pest_evaluable_it_registers_collected_invoice_queue_endpoints_in_orderInvoicesRoute":7,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_uses_a_consistent_unavailable_service_contract_for_missing_queue_dependencies":7,"P\\Tests\\Unit\\Invoicing\\EconomicTransferOrderItemSkipTest::__pest_evaluable_it_wires_zero_cost_and_zero_quantity_skip_guard_into_transfer_line_builder":7,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_processes_queued_transfer_jobs_to_completion":1,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_deduplicates_active_jobs_per_transfer_target":1,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_rejects_invalid_payloads_without_inserting_queue_rows":1,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_supports_fail_retry_and_reprocess_lifecycle_transitions":1,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_supports_synchronous_fallback_branches_before_queue_enqueue_on_economic_invoice_export_routes":7,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_keeps_queue_status_endpoints_guarded_while_collected_invoice_export_routes_support_synchronous_fallback":7,"P\\Tests\\Unit\\Invoicing\\UserCollectedInvoiceUpdateRouteValidationTest::__pest_evaluable_it_supports_independent_po__number_and_closed__at_updates_for_PUT__collected_invoices_in_user_route":7,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueRouteHardeningTest::__pest_evaluable_it_returns_pagination_metadata_and_strict_status_handling_for_collected_invoice_queue_list":7,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueRouteHardeningTest::__pest_evaluable_it_enforces_retry_constraints_for_collected_invoice_queue_jobs_before_retry_execution":7,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_collected_queue_list_metadata_and_strict_status_filter_enum_in_openapi":7,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_builds_a_completed_collected_invoice_queue_summary_from_payload_and_result":8,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_prefers_queue_error_messages_for_failed_jobs_and_keeps_null_safe_outcome_fields":8,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_uses_deterministic_status_message_fallback_when_no_explicit_message_exists":8,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_handles_object_payload_result_values_and_malformed_fields_without_throwing":8,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueWorkerTickRouteTest::__pest_evaluable_it_ticks_collected_invoice_transfer_queue_from_queue_list_and_status_routes":7,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueWorkerTickRouteTest::__pest_evaluable_it_ticks_order_transfer_queue_from_draft_and_invoice_status_routes":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_builds_the_installer_around_the_PHP_agent_artifacts_and_management_polling_config":8,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_exposes_update_payload__credential_rotation__and_operation_endpoints_without_shell_transport_wiring":8,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_relay_fallback_and_transport_health_details_without_shell_or_update_runtime_state":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_resolves_edge_agent_artifacts_from_the_source_tree_layout":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_prioritizes_mounted_and_baked_in_artifact_directories_before_repo_fallbacks":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_prioritizes_router_resources_before_mounted_and_baked_in_artifact_directories":7,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_lists_orders_for_an_admin_scoped_user_and_limits_the_results_to_the_permitted_departments":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_lists_only_the_targeted_customer_orders_for_subuser_sessions":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_returns_the_current_auth_and_permission_failures_when_order_listing_is_not_allowed":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_creates_orders_through_the_real_endpoint":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_order_creation_requests":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_updates_orders_through_the_primary_endpoint":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_reassigns_invoice_collections_when_changing_an_order_across_the_draft_customer_boundary":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_supports_legacy_field_value_metadata_updates_through_the_primary_endpoint":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_updates_through_the_primary_order_endpoint":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_updates_orders_through_the_legacy_alias_endpoint":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_supports_legacy_field_value_metadata_updates_through_the_alias_endpoint":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_updates_through_the_legacy_alias_endpoint":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_unsupported_legacy_field_value_updates_on_both_update_endpoints":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_deletes_orders_through_the_real_endpoint":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_order_delete_requests":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_regenerates_attached_wash_certificates_when_certificate_metadata_changes_through_the_primary_endpoint":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_regenerates_attached_wash_certificates_for_legacy_field_value_updates_through_the_alias_endpoint":8,"P\\Tests\\Api\\ApiCoverageManifestTest::__pest_evaluable_it_keeps_every_selected_API_operation_covered_by_happy_path_and_failure_tests":8,"P\\Tests\\Api\\ApiCoverageManifestTest::__pest_evaluable_it_keeps_the_OpenAPI_manifest_entries_aligned_with_the_API_spec":8,"P\\Tests\\Api\\ApiFixturesCleanupTest::__pest_evaluable_it_removes_generated_customer_traces_during_fixture_cleanup":8,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_logs_in_with_valid_customer_credentials":8,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_rejects_invalid_login_payloads_and_credentials":8,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_returns_the_cached_auth_session_payload_for_a_valid_token":8,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_includes_economic_runtime_config_for_uncached_auth_sessions":8,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_rejects_invalid_auth_session_tokens":8,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_logs_out_and_invalidates_the_token_for_future_session_calls":8,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_logs_out_and_invalidates_cached_subuser_sessions":8,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_rejects_invalid_logout_tokens":8,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_returns_the_raw_202_gather_webhook_contract_over_HTTP":8,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_still_prompts_for_department_selection_when_only_one_department_is_eligible":8,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_accepts_the_legacy_initial_webhook_body_with_top_level_call_identifiers":8,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_returns_native_flow_gather_data_after_a_top_level_department_selection_when_distinct_gate_choices_exist":8,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_opens_the_gate_immediately_after_a_top_level_department_selection_when_entrance_and_exit_share_the_same_gate":8,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_uses_a_multi_digit_gather_contract_when_10_departments_are_eligible":8,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_returns_a_raw_400_transport_error_for_malformed_webhook_payloads":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_lists_only_visible_departments_and_can_return_a_single_department_with_the_slack_webhook":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_department_listing_when_the_permission_is_missing":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_creates_departments_through_the_real_endpoint":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_invalid_department_create_requests":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_updates_departments_through_the_real_endpoint":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_invalid_department_update_requests":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_lists_department_categories_for_a_department":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_invalid_department_category_requests":8,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_lists_the_draft_customer_config_entry_in_economic_config_responses":8,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_round_trips_the_draft_customer_config_value_through_economic_config_updates":8,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_rejects_order_draft_exports_for_the_configured_draft_customer":8,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_rejects_booked_invoice_exports_for_the_configured_draft_customer":8,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_rejects_collected_invoice_exports_for_the_configured_draft_customer":8,"P\\Tests\\Api\\PingApiTest::__pest_evaluable_it_returns_the_ping_contract":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_returns_a_setup_required_error_when_department_terminal_readers_are_requested_without_terminal_setup":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_sends_a_Stripe_invoice_by_email_and_persists_the_hosted_invoice_association":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_returns_a_conflict_when_a_Stripe_hosted_invoice_is_already_active_for_the_order":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_allows_sending_a_new_Stripe_hosted_invoice_when_the_existing_association_is_already_terminal":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_voids_an_unpaid_Stripe_hosted_invoice_and_clears_the_local_order_association":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_refuses_to_cancel_a_paid_Stripe_hosted_invoice":8,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_returns_a_setup_required_error_when_creating_a_payment_intent_for_a_department_without_terminal_setup":8,"P\\Tests\\Api\\VehiclesApiTest::__pest_evaluable_it_returns_the_newest_vehicle_last__order__id_that_still_has_order_items":8,"P\\Tests\\Api\\VehiclesApiTest::__pest_evaluable_it_returns_a_null_vehicle_last__order__id_when_no_order_with_items_exists":8,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayViewCacheTest::__pest_evaluable_it_syncs_gateway_snapshots_into_cached_list_payloads_and_refreshes_fleet_usage":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayModuleRouteWiringTest::__pest_evaluable_it_keeps_only_the_module_facade_in_the_global_classes_directory_and_conditionally_loads_module_routes":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_install_script_urls_with_the_compose_edge_gateway_artifacts_and_forwarded_https_scheme":8,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_builds_the_installer_around_the_compose_stack_artifacts_and_management_polling_config":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_exposes_update_payload__credential_rotation__cancel_endpoints__and_operation_endpoints_without_shell_transport_wiring":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayRouteWiringTest::__pest_evaluable_it_registers_the_v2_operator_facing_edge_gateway_routes":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewaySchemaBootstrapTest::__pest_evaluable_it_defines_the_v2_edge_gateway_schema_bootstrap_tables":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewaySchemaBootstrapTest::__pest_evaluable_it_stores_operation_metadata_and_event_timelines_for_management_workflows":7,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_prefers_EDGE__PUBLIC__API__URL_when_explicitly_configured":8,"P\\Tests\\Api\\PlateScannersApiTest::__pest_evaluable_it_returns_the_updated_lane_id_after_editing_a_scanner_whose_null_lane_was_already_cached":8,"P\\Tests\\Api\\EdgeGatewayAgentApiTest::__pest_evaluable_it_serves_installer_artifacts_and_recovers_gateway_runtime_status_after_fresh_heartbeats":8,"P\\Tests\\Api\\EdgeGatewayAgentApiTest::__pest_evaluable_it_polls_operations_and_commands__submits_results__and_records_broker_presence_for_task_pages":8,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_builds_broker_backlog_and_completes_gateway_operations_through_broker_endpoints":8,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_creates_install_tokens__tracks_installer_status__and_exposes_claimed_gateway_detail_to_authorized_operators":8,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_manages_edge_gateway_metadata__bindings__operations__sessions__rotation__cutover__and_deletion":8,"P\\Tests\\Integration\\EdgeGateway\\EdgeGatewayBackendIntegrationTest::__pest_evaluable_it_assembles_tasks__logs__statistics__operations__commands__and_shell_lifecycle_state_from_persisted_records":1,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_rejects_invalid_edge_broker_shared_secrets":8,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_rejects_operator_edge_routes_when_module_permission_or_department_access_is_missing":8,"P\\Tests\\Api\\EdgeGatewayAgentApiTest::__pest_evaluable_it_rejects_missing_and_invalid_edge_agent_tokens":8,"P\\Tests\\Integration\\EdgeGateway\\EdgeGatewayBackendIntegrationTest::__pest_evaluable_it_persists_gateway_cutover_relay_bindings_used_by_self_serve_Shelly_dispatch":1,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_validates_broker_sessions_and_ingests_presence__telemetry__logs__and_shell_lifecycle_data":8,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_rejects_shell_session_creation_while_broker_presence_is_unavailable":8,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_ignores_and_soft_deletes_edge_gateways_whose_department_no_longer_exists":8,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_appends_forwarded_ports_when_the_forwarded_host_omits_them":8,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_websocket_broker_urls_on_the_traefik_broker_path":8,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_localhost_websocket_broker_urls_on_the_local_traefik_api_prefix":8,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_keeps_root_host_api_urls_unprefixed_when_the_request_is_not_under_the_local_api_alias":8,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_resolves_simulator_gateway_service_bindings_from_lane_relay_slots":7,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_counts_overtime_minutes_when_a_saved_shift_end_extends_past_the_approved_original_end":7,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_counts_unapproved_overtime_from_a_bounded_shift_updateTime_fallback":7,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_caps_current_slot_hours_to_elapsed_minutes_and_zeroes_future_slots":7,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_round_trips_the_default_distribution_department_config_value_through_economic_config_updates":8,"P\\Tests\\Unit\\Replication\\ReplicationSecretBoxTest::__pest_evaluable_it_encrypts_replication_secrets_without_storing_plaintext":8,"P\\Tests\\Unit\\Replication\\ReplicationSecretBoxTest::__pest_evaluable_it_builds_active_database_and_redis_config_from_encrypted_bootstrap_snapshots":8,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_does_not_require_replica_SQL_threads_before_database_provisioning_configures_them":7,"P\\Tests\\Unit\\Invoicing\\EconomicDraftCustomerOpenApiSpecTest::__pest_evaluable_it_documents_transaction_draft_customer_config_and_auth_runtime_fields_in_all_tracked_openapi_copies":1,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_computes_MinIO_free_space_and_catch_up_math_safely":7,"P\\Tests\\Integration\\DailyReports\\DepartmentDailyReportComplaintsIntegrationTest::__pest_evaluable_it_counts_complaint_rows_by_department_and_created__at_reporting_range":1,"P\\Tests\\Integration\\DailyReports\\DepartmentDailyReportComplaintsIntegrationTest::__pest_evaluable_it_updates_and_deletes_complaint_rows":1,"P\\Tests\\Integration\\DailyReports\\DepartmentDailyReportComplaintsIntegrationTest::__pest_evaluable_it_parses_created__by__name_from_the_users_table":1,"P\\Tests\\Integration\\DailyReports\\DepartmentOutsideHoursStatisticsServiceIntegrationTest::__pest_evaluable_it_integrates_orders__xlvask__and_self_serve_into_one_outside_hours_summary_with_missing_hours_diagnostics":1,"P\\Tests\\Integration\\EdgeGateway\\EdgeGatewayBackendIntegrationTest::__pest_evaluable_it_persists_install_session_updates_and_derives_gateway_runtime_status_from_heartbeats":1,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_processes_only_collected_invoice_jobs_and_respects_the_manual_batch_limit":1,"P\\Tests\\Integration\\SystemStatus\\SuperuserSystemStatusInfrastructureProbeTest::__pest_evaluable_it_can_probe_the_configured_database_in_integration_mode":1,"P\\Tests\\Integration\\SystemStatus\\SuperuserSystemStatusInfrastructureProbeTest::__pest_evaluable_it_can_probe_redis_in_integration_mode_when_configured":1,"P\\Tests\\Integration\\SystemStatus\\SuperuserSystemStatusInfrastructureProbeTest::__pest_evaluable_it_can_probe_minio_in_integration_mode_when_configured":1,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_creates_Coolify_service_payloads_from_raw_compose_without_a_service_type":7,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_uses_the_selected_Coolify_project_and_resolves_server_UUID_from_the_instance_default":8,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_creates_Coolify_GitHub_App_application_payloads_so_pulls_use_the_app_token":7,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_uses_the_self_contained_Coolify_API_Dockerfile_for_API_applications":7,"P\\Tests\\Unit\\Tooling\\ComposerEntrypointTest::__pest_evaluable_it_checks_PSR_HTTP_message_interfaces_before_trusting_a_Composer_vendor_tree":7,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_defines_release_manager_schema__routes__permissions__and_system_status_integration_hooks":7,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_requires_non_default_release_channel_runtime_URLs_and_preserves_load_balancer_paths":7,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_wires_MinIO_replication_through_routes_and_bootstrap_snapshots":7,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_defines_Coolify_schema__route_permissions__and_replication_integration_hooks":7,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_chooses_a_requested_runtime_channel_only_when_it_is_available_to_the_principal":8,"P\\Tests\\Api\\OrderBookingsCompletionApiTest::__pest_evaluable_it_rejects_standalone_order_booking_completion_outside_POS":8,"P\\Tests\\Api\\OrderBookingsCompletionApiTest::__pest_evaluable_it_allows_linked_POS_order_booking_completion_for_mobile_POS_compatibility":8,"P\\Tests\\Api\\OrderBookingsCompletionApiTest::__pest_evaluable_it_accepts_numeric_safety_seal_strings_when_completing_a_linked_POS_order_booking":8,"P\\Tests\\Api\\OrderBookingsCompletionApiTest::__pest_evaluable_it_disables_the_legacy_complete_wash_without_certificate_route":8,"P\\Tests\\Api\\OrderBookingsUpdateApiTest::__pest_evaluable_it_detaches_an_order_booking_and_clears_the_matching_order_link":8,"P\\Tests\\Api\\OrderBookingsUpdateApiTest::__pest_evaluable_it_keeps_the_linked_order_when_order__id_is_omitted_from_an_order_booking_update":8,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_allows_explicit_runtime_selection_of_any_enabled_release_channel":7,"P\\Tests\\Api\\SelfserveLaneWashInProgressApiTest::__pest_evaluable_it_requires_authentication_before_checking_in_progress_wash_permissions":8,"P\\Tests\\Api\\SelfserveLaneWashInProgressApiTest::__pest_evaluable_it_reports_both_elevated_and_customer_self_serve_permissions_when_lane_polling_is_not_allowed":8,"P\\Tests\\Api\\SelfserveLaneWashInProgressApiTest::__pest_evaluable_it_allows_customer_self_serve_permission_to_view_their_own_in_progress_wash_details":8,"P\\Tests\\Api\\SelfserveLaneWashInProgressApiTest::__pest_evaluable_it_redacts_another_customers_in_progress_wash_from_customer_self_serve_lane_polling":8,"P\\Tests\\Api\\SelfserveFixtureApiTest::__pest_evaluable_it_creates_a_comprehensive_self_serve_API_scenario_with_demo_relays":8,"P\\Tests\\Api\\SelfserveZZZShellyGuardApiTest::__pest_evaluable_it_did_not_record_any_real_Shelly_request_attempts_during_self_serve_API_tests":8,"P\\Tests\\Api\\ApiFixturesCleanupTest::__pest_evaluable_it_restores_preserved_module_config_rows_during_fixture_cleanup":8,"P\\Tests\\Api\\BrandingApiTest::__pest_evaluable_it_creates_lists_and_updates_complete_branding_values":8,"P\\Tests\\Api\\BrandingApiTest::__pest_evaluable_it_rejects_branding_requests_without_permissions_or_valid_input":8,"P\\Tests\\Api\\BrandingApiTest::__pest_evaluable_it_assigns_and_clears_department_branding_for_superusers":8,"P\\Tests\\Api\\BrandingApiTest::__pest_evaluable_it_rejects_invalid_department_branding_assignments":8,"P\\Tests\\Api\\CollectedInvoiceMonthlySplitApiTest::__pest_evaluable_it_previews_monthly_split_changes_without_moving_orders_or_creating_collections":8,"P\\Tests\\Api\\CollectedInvoiceMonthlySplitApiTest::__pest_evaluable_it_splits_a_selected_March_and_April_collected_invoice_into_monthly_collections":8,"P\\Tests\\Api\\CollectedInvoiceMonthlySplitApiTest::__pest_evaluable_it_sets_closed__at_to_month_end_when_split_month_has_ended":8,"P\\Tests\\Api\\CollectedInvoiceMonthlySplitApiTest::__pest_evaluable_it_splits_the_whole_affected_collection_even_when_only_one_month_is_selected":8,"P\\Tests\\Api\\CollectedInvoiceMonthlySplitApiTest::__pest_evaluable_it_does_not_affect_booked_collected_invoices":8,"P\\Tests\\Api\\CollectedInvoiceMonthlySplitApiTest::__pest_evaluable_it_skips_draft_linked__Stripe__and_single_month_collections":8,"P\\Tests\\Api\\CollectedInvoiceMonthlySplitApiTest::__pest_evaluable_it_rejects_invalid_monthly_split_date_ranges":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_allows_superusers_to_filter_archived_departments":8,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_does_not_allow_regular_department_listings_to_reveal_archived_departments_through_filters":8,"P\\Tests\\Api\\EdgeGatewayConfigApiTest::__pest_evaluable_it_stores_broker_settings_in_edge_gateway_module_config_and_uses_them_for_shell_sessions":8,"P\\Tests\\Api\\EdgeGatewayConfigApiTest::__pest_evaluable_it_returns_broker_diagnostics_for_the_current_edge_gateway_module_config_values":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_defaults_order_PO_from_a_linked_booking_when_creating_without_an_order_PO":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_keeps_an_explicit_order_PO_when_creating_a_linked_order":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_defaults_blank_order_PO_from_a_linked_booking_on_order_updates":8,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_requires_explicit_confirmation_before_deleting_protected_orders":8,"P\\Tests\\Api\\PlateScannersApiTest::__pest_evaluable_it_rejects_plate_scanner_edits_when_the_permission_is_missing":8,"P\\Tests\\Api\\ReferenceSuggestionsApiTest::__pest_evaluable_it_returns_ranked_POS_reference_suggestions_from_bookings__orders__and_customer_vehicles":8,"P\\Tests\\Api\\ReferenceSuggestionsApiTest::__pest_evaluable_it_orders_reference_suggestions_by_match_relevance_before_context_and_frequency":8,"P\\Tests\\Api\\ReferenceSuggestionsApiTest::__pest_evaluable_it_enforces_authentication__list_permission__and_department_access_for_reference_suggestions":8},"times":{"P\\Tests\\Unit\\Auth\\CreateTokenTest::__pest_evaluable_it_creates_a_64_char_auth_token_for_an_existing_user":0.11,"P\\Tests\\Unit\\Auth\\CreateTokenTest::__pest_evaluable_it_throws_a_clear_exception_when_customer_user_is_missing":0.017,"P\\Tests\\Unit\\Auth\\LegacyAuthScriptParityTest::__pest_evaluable_it_keeps_passkey_challenge_legacy_script_green_during_migration":0.032,"P\\Tests\\Unit\\Auth\\LegacyAuthScriptParityTest::__pest_evaluable_it_keeps_register_CVR_legacy_script_green_during_migration":0.035,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_builds_deterministic_department_digit_map_and_caps_to_9_options":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_resolves_department_ids_by_selected_digit":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_resolves_gate_type_digit_to_entrance_or_exit":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_stores__loads_and_clears_ivr_state_with_ttl":0.001,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_validates_state_caller_fingerprint_matching":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_builds_department_and_gate_prompts":0,"P\\Tests\\Unit\\Permissions\\PermissionNodeDefinitionTest::__pest_evaluable_it_creates_permission_nodes_linked_to_subuser_permission_keys":0,"P\\Tests\\Unit\\Permissions\\PermissionNodeDefinitionTest::__pest_evaluable_it_rejects_empty_permission_definitions":0,"P\\Tests\\Unit\\Selfserve\\NormalizationTest::__pest_evaluable_it_normalizes_button_arrays__json_and_csv_inputs":0,"P\\Tests\\Unit\\Selfserve\\NormalizationTest::__pest_evaluable_it_rejects_invalid_button_inputs":0,"P\\Tests\\Unit\\Selfserve\\NormalizationTest::__pest_evaluable_it_normalizes_vehicle_type_values_and_null_semantics":0,"P\\Tests\\Unit\\Selfserve\\NormalizationTest::__pest_evaluable_it_rejects_invalid_vehicle_type_values":0,"P\\Tests\\Unit\\Selfserve\\NormalizationTest::__pest_evaluable_it_rejects_non_numeric_vehicle_type_strings":0,"P\\Tests\\Unit\\Selfserve\\NormalizationTest::__pest_evaluable_it_keeps_lane_service_enum_contract_for_MACHINE":0,"P\\Tests\\Unit\\Subusers\\SubuserGrantInitializationTest::__pest_evaluable_it_initializes_booking_node_grants_without_DB_dependency":0,"P\\Tests\\Unit\\Subusers\\SubuserGrantInitializationTest::__pest_evaluable_it_initializes_selfserve_node_grants_without_DB_dependency":0,"P\\Tests\\Unit\\Subusers\\SubusersRoutePermissionLinkTest::__pest_evaluable_it_keeps_subusers_route_list_permission_linked_to_SUBUSERS__LIST_node":0.009,"P\\Tests\\Unit\\Search\\SystemSearchEntityTypeCoverageTest::__pest_evaluable_it_keeps_route_and_service_entity_type_registries_in_sync_with_expanded_coverage":0,"P\\Tests\\Unit\\Search\\SystemSearchEntityTypeCoverageTest::__pest_evaluable_it_documents_every_supported_search_entity_type_in_openapi_enum":0.047,"P\\Tests\\Unit\\Search\\SystemSearchEconomicCustomerIndexWiringTest::__pest_evaluable_it_wires_local_e_conomic_customer_index_into_customer_related_search_entities":0.016,"P\\Tests\\Unit\\Search\\SystemSearchEconomicCustomerIndexWiringTest::__pest_evaluable_it_registers_cron_tasks_that_keep_the_e_conomic_search_index_refreshed":0.018,"P\\Tests\\Unit\\Search\\SystemSearchInvalidationHooksTest::__pest_evaluable_it_marks_system_search_cache_dirty_from_generic_db_object_mutation_flows":0.052,"P\\Tests\\Unit\\Search\\SystemSearchInvalidationHooksTest::__pest_evaluable_it_marks_system_search_cache_dirty_from_object_property_mutations":0.01,"P\\Tests\\Unit\\Search\\SystemSearchInvalidationHooksTest::__pest_evaluable_it_marks_system_search_cache_dirty_when_module_config_values_change":0.008,"P\\Tests\\Unit\\Search\\SystemSearchInvalidationHooksTest::__pest_evaluable_it_registers_cron_maintenance_task_for_system_search_cache":0.075,"P\\Tests\\Unit\\Search\\SystemSearchOpenAiIntentParserTest::__pest_evaluable_it_redacts_obvious_sensitive_fragments_before_sending_query_to_intent_parser":0.001,"P\\Tests\\Unit\\Search\\SystemSearchOpenAiIntentParserTest::__pest_evaluable_it_builds_payload_with_redacted_query_and_parses_strict_JSON_output":0.001,"P\\Tests\\Unit\\Search\\SystemSearchOpenAiIntentParserTest::__pest_evaluable_it_falls_back_safely_when_OpenAI_is_disabled":0,"P\\Tests\\Unit\\Search\\SystemSearchOpenAiIntentParserTest::__pest_evaluable_it_handles_malformed_OpenAI_response_payloads_without_throwing":0,"P\\Tests\\Unit\\Search\\SystemSearchOpenAiIntentParserTest::__pest_evaluable_it_uses_parser_cache_for_identical_query_and_allowed_type_combinations":0,"P\\Tests\\Unit\\Search\\SystemSearchOpenAiIntentParserTest::__pest_evaluable_it_caps_alias_and_hint_payloads_from_OpenAI_and_filters_hints_to_allowed_types":0,"P\\Tests\\Unit\\Search\\SystemSearchOpenApiSpecTest::__pest_evaluable_it_documents_system_wide_search_endpoints_in_openapi":0.106,"P\\Tests\\Unit\\Search\\SystemSearchOpenApiSpecTest::__pest_evaluable_it_documents_debug__intent_and_parser_metadata_schema_in_openapi":0.035,"P\\Tests\\Unit\\Search\\SystemSearchOpenApiSpecTest::__pest_evaluable_it_documents_e_conomic_indexed_customer_matching_and_synonym_behavior":0.022,"P\\Tests\\Unit\\Search\\SystemSearchRouteBehaviorTest::__pest_evaluable_it_normalizes_type_lists_from_csv_json_and_arrays":0,"P\\Tests\\Unit\\Search\\SystemSearchRouteBehaviorTest::__pest_evaluable_it_parses_booleans_and_clamps_integers_using_route_defaults":0,"P\\Tests\\Unit\\Search\\SystemSearchRouteBehaviorTest::__pest_evaluable_it_exposes_expected_searchable_entity_types":0,"P\\Tests\\Unit\\Search\\SystemSearchRouteWiringTest::__pest_evaluable_it_registers_system_wide_search_GET_and_POST_endpoints_with_intent_debug_support":0.028,"P\\Tests\\Unit\\Search\\SystemSearchRouteWiringTest::__pest_evaluable_it_registers_superuser_cache_clear_and_rebuild_endpoints_for_system_search":0.031,"P\\Tests\\Unit\\Search\\SystemSearchRouteWiringTest::__pest_evaluable_it_passes_permission_and_own_scope_context_into_system_search_service":0.008,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_does_not_invoke_intent_parser_when_lexical_confidence_is_already_high":0.001,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_invokes_parser_on_low_confidence_lexical_results_and_applies_hints_only_boosts":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_never_lets_parser_entity_hints_override_explicit_include_filters":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_returns_fallback_parser_metadata_when_parser_fails_gracefully":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_scopes_query_cache_by_permission_context_to_avoid_cross_user_cache_leakage":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_caps_AI_driven_expanded_terms_to_prevent_query_amplification":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_expands_danish_discount_wording_into_lexical_discount_synonyms":0.003,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_invokes_parser_for_intent_driven_natural_language_queries_even_when_lexical_score_is_high":0.003,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_uses_association_hints_to_pull_related_customer_records_from_non_customer_matches":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_prefers_newer_records_when_relevance_scores_are_comparable":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_keeps_explicit_identifier_matches_ahead_of_newer_but_weaker_records":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_promotes_invoices_orders_order_bookings_and_customers_in_ranking":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_never_prioritizes_cancelled_bookings_over_active_bookings":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_heavily_demotes_configured_low_priority_entity_types_in_ranking":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_tokenizes_unicode_names_without_stripping_non_ascii_letters":0.001,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_does_not_treat_explicit_identifier_queries_as_intent_driven":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_requires_broader_term_coverage_for_multi_word_scoring":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_enriches_object_attachment_results_with_associated_customer_context":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_includes_the_economic_customer_index_in_cache_dependencies_for_customer_scoped_results":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_falls_back_to_invoice_date_ranges_when_invoice_names_are_missing":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_derives_xlvask_customer_numbers_only_from_digits_only_extern_ids":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_replaces_unnamed_user_titles_with_the_customer_context_name":0,"P\\Tests\\Unit\\Search\\SystemSearchServiceIntentFlowTest::__pest_evaluable_it_uses_the_goal_criteria_label_for_department_goal_titles":0,"P\\Tests\\Integration\\Search\\SystemSearchCacheIntegrationTest::__pest_evaluable_it_reuses_parser_cache_entries_for_repeated_natural_language_intent_requests":0.129,"P\\Tests\\Integration\\Search\\SystemSearchCacheIntegrationTest::__pest_evaluable_it_clears_parser_cache_namespace_via_clearAll_to_force_a_fresh_parse":0,"P\\Tests\\Integration\\Search\\SystemSearchCacheIntegrationTest::__pest_evaluable_it_bumps_per_table_cache_versions_when_a_dirty_table_marker_is_registered":0.05,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_falls_back_to_system_orders_for_fixed_pricing_when_regular_orders_yield_no_customers":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_falls_back_to_system_orders_for_wash_subscriptions_when_regular_orders_yield_no_customers":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2CliBackfillCommandTest::__pest_evaluable_it_registers_economic_v2_backfill_command_in_cli_dispatcher":0.005,"P\\Tests\\Unit\\Invoicing\\EconomicV2CliBackfillCommandTest::__pest_evaluable_it_provides_a_backfill_cron_script_entrypoint":0.007,"P\\Tests\\Unit\\Invoicing\\EconomicV2CompareEngineTest::__pest_evaluable_it_returns_exact__match_when_totals_lines_and_departments_are_identical":0.001,"P\\Tests\\Unit\\Invoicing\\EconomicV2CompareEngineTest::__pest_evaluable_it_returns_total__mismatch_when_only_totals_differ":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2CompareEngineTest::__pest_evaluable_it_detects_line_level_mismatches_for_quantity_and_price":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2CompareEngineTest::__pest_evaluable_it_detects_departmental_distribution_mismatches":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2CompareEngineTest::__pest_evaluable_it_returns_missing__target_when_draft_or_booked_target_is_unavailable":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2LineNormalizerTest::__pest_evaluable_it_normalizes_draft_invoice_lines_including_departmental_distributions":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2LineNormalizerTest::__pest_evaluable_it_marks_text_only_zero_value_lines_as_non_billable_and_keeps_deterministic_key":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2LineNormalizerTest::__pest_evaluable_it_normalizes_booked_invoices_and_computes_net_total_delta_from_lines":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2LineNormalizerTest::__pest_evaluable_it_contains_internal_normalization_path_with_departmental_metadata_support":0.005,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_documents_economic_v2_invoice_paths_in_openapi":0.016,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_documents_v2_historical_distribution_and_pricing_history_paths_in_openapi":0.011,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_aligns_legacy_compare_schema_with_runtime_payload_by_removing_stale_required_order__ids":0.012,"P\\Tests\\Unit\\Invoicing\\EconomicV2OpenApiSpecTest::__pest_evaluable_it_defines_new_reusable_v2_schemas_for_normalization_comparison_versioning_and_distribution":0.01,"P\\Tests\\Unit\\Invoicing\\EconomicV2RevenueAndBarredSupportTest::__pest_evaluable_it_supports_barred_filtering_when_listing_e_conomic_customers":0.08,"P\\Tests\\Unit\\Invoicing\\EconomicV2RevenueAndBarredSupportTest::__pest_evaluable_it_implements_a_dedicated_v2_e_conomic_revenue_statistics_service_and_route":0.014,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_registers_all_collected_invoice_economic_v2_routes_with_explicit_permissions":0.006,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_registers_version_aware_distribution_and_pricing_history_v2_routes":0.008,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_caches_v2_distribution_responses_with_a_configurable_redis_ttl":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_writes_fixed_pricing_versions_from_create_and_delete_flows":0.005,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_writes_vehicle_subscription_versions_for_create_update_delete_flows":0.003,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_writes_discount_override_versions_from_superuser_discounts_route":0.007,"P\\Tests\\Unit\\Invoicing\\EconomicV2RouteAndVersioningHooksTest::__pest_evaluable_it_keeps_legacy_compare_endpoint_path_for_backward_compatibility":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicV2VersioningServiceStructureTest::__pest_evaluable_it_implements_effective_range_lifecycle_methods_for_all_versioned_entities":0.036,"P\\Tests\\Unit\\Invoicing\\EconomicV2VersioningServiceStructureTest::__pest_evaluable_it_closes_previous_active_interval_before_inserting_a_new_version":0.021,"P\\Tests\\Unit\\Invoicing\\EconomicV2VersioningServiceStructureTest::__pest_evaluable_it_includes_best_effort_backfill_with_provenance_and_confidence_metadata":0.005,"P\\Tests\\Unit\\Invoicing\\EconomicV2VersioningServiceStructureTest::__pest_evaluable_it_anchors_historical_resolution_on_order_created__at_timestamps_in_distribution_service":0.013,"P\\Tests\\Unit\\Invoicing\\InvoicingOrdersCalculationsHardeningTest::__pest_evaluable_it_guards_against_empty_order_ids_in_net_amount_calculation":0.081,"P\\Tests\\Unit\\Invoicing\\InvoicingOrdersCalculationsHardeningTest::__pest_evaluable_it_guards_against_empty_customer_arrays_in_date_range_transaction_fetches":0.074,"P\\Tests\\Unit\\Invoicing\\InvoicingOrdersCalculationsHardeningTest::__pest_evaluable_it_uses_centralized_duplicate_filtering_for_possible_duplicate_detection":0.081,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteCacheHelpersTest::__pest_evaluable_it_uses_sane_ttl_defaults_and_clamps_negative_ttl_to_zero":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteCacheHelpersTest::__pest_evaluable_it_builds_deterministic_cache_keys_per_scope_and_date_range":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteCacheHelpersTest::__pest_evaluable_it_falls_back_to_resolver_directly_when_cache_ttl_is_disabled":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteGuardsTest::__pest_evaluable_it_requires_superuser_permission_for_invoicing_period_distribution_all_endpoint":0.05,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteGuardsTest::__pest_evaluable_it_uses_shared_date_range_normalization_across_invoicing_period_endpoints":0.705,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodUtilsTest::__pest_evaluable_it_normalizes_a_valid_invoicing_date_range_to_full_day_timestamps":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodUtilsTest::__pest_evaluable_it_rejects_invalid_date_formats":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodUtilsTest::__pest_evaluable_it_rejects_descending_date_ranges":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodUtilsTest::__pest_evaluable_it_finds_duplicate_orders_regardless_of_original_input_order":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_runs_best_effort_backfill_when_fixed_pricing_version_history_is_empty":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConditionEvaluatorTest::__pest_evaluable_it_evaluates_self_serve_conditions_with_combined_AND_and_OR_semantics":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConditionEvaluatorTest::__pest_evaluable_it_prefers_condition_results_over_question_answers_when_evaluating_task_gates":0,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_self_serve_machine_type__eligibility__summary__and_webhook_endpoints":0.008,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_defines_reusable_self_serve_wash_and_machine_type_schemas":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_self_serve_machine_types__eligibility__summaries__and_machine_start_webhook_routes":0.012,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_keeps_machine_type_support_wired_into_lanes__tasks__and_conditions_routes":0.014,"P\\Tests\\Unit\\N8n\\N8nRouteHelpersTest::__pest_evaluable_it_normalizes_webhook_methods_and_falls_back_to_POST_for_unsupported_verbs":0,"P\\Tests\\Unit\\N8n\\N8nRouteHelpersTest::__pest_evaluable_it_filters_request_parameters_down_to_the_allowed_n8n_query_keys":0,"P\\Tests\\Unit\\N8n\\N8nRouteWiringTest::__pest_evaluable_it_registers_workflow_lifecycle_endpoints_for_the_n8n_module":0.182,"P\\Tests\\Unit\\N8n\\N8nRouteWiringTest::__pest_evaluable_it_registers_execution_and_webhook_trigger_endpoints_for_the_n8n_module":0.115,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_redistributes_booked_department_75_net_amounts_using_fixed_pricing_and_wash_subscription_weights":0.001,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_keeps_classified_booked_department_75_amounts_undistributed_when_no_monthly_basis_exists":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_keeps_unclassified_booked_department_75_lines_undistributed_with_warnings":0,"P\\Tests\\Unit\\Invoicing\\EconomicEndpointUrlEncodingTest::__pest_evaluable_it_encodes_raw_filter_query_values_that_include_timestamps":0,"P\\Tests\\Unit\\Invoicing\\EconomicEndpointUrlEncodingTest::__pest_evaluable_it_avoids_double_encoding_query_values_that_are_already_escaped":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_supports_bulk_booked_invoice_line_payloads_with_top_level_department_numbers":0,"P\\Tests\\Unit\\Redis\\RedisAtomicReservationTest::__pest_evaluable_it_claims_a_slot_atomically_with_nx_and_expiration":0,"P\\Tests\\Unit\\Redis\\RedisAtomicReservationTest::__pest_evaluable_it_returns_false_when_the_slot_is_already_claimed_and_clamps_ttl_to_one_second":0,"P\\Tests\\Unit\\Bookings\\OrderBookingRouteIdempotencyGuardTest::__pest_evaluable_it_guards_order_booking_creation_with_redis_backed_idempotency":0.101,"P\\Tests\\Unit\\DynamicImages\\DynamicImagePreRenderCronWiringTest::__pest_evaluable_it_registers_dynamic_image_pre_render_cron_task_and_related_helpers":0.01,"P\\Tests\\Integration\\Database\\DbConnectionTest::__pest_evaluable_it_can_connect_to_a_configured_MySQL_instance_in_integration_mode":0.016,"P\\Tests\\Integration\\Invoicing\\EconomicV2BackfillAndDistributionIntegrationTest::__pest_evaluable_it_runs_best_effort_backfill_repeatedly_without_introducing_duplicate_same_start_rows":0.013,"P\\Tests\\Integration\\Invoicing\\EconomicV2BackfillAndDistributionIntegrationTest::__pest_evaluable_it_resolves_version_aware_distribution_payload_shapes_over_a_real_date_range":0.016,"P\\Tests\\Integration\\Invoicing\\EconomicV2VersioningServiceIntegrationTest::__pest_evaluable_it_creates_closes_and_rotates_fixed_pricing_versions_without_overlap":0.015,"P\\Tests\\Integration\\Invoicing\\EconomicV2VersioningServiceIntegrationTest::__pest_evaluable_it_tracks_vehicle_and_discount_version_lifecycles_with_closure_semantics":0.014,"P\\Tests\\Integration\\Permissions\\LegacyPermissionRedisCacheScriptTest::__pest_evaluable_it_keeps_legacy_route_permission_redis_cache_script_green":0.01,"P\\Tests\\Unit\\Permissions\\AllowOwnOrDepartmentAccessForbiddenTest::__pest_evaluable_it_returns_both_own_and_elevated_permissions_when_both_are_missing":0,"P\\Tests\\Unit\\Permissions\\AllowOwnOrDepartmentAccessForbiddenTest::__pest_evaluable_it_returns_only_elevated_permission_when_own_permission_exists_but_own_context_fails":0,"P\\Tests\\Unit\\Permissions\\AllowOwnOrDepartmentAccessForbiddenTest::__pest_evaluable_it_allows_elevated_permission_path_without_forbidden_and_validates_department_access":0,"P\\Tests\\Unit\\Permissions\\ForbiddenResponseWiringTest::__pest_evaluable_it_routes_and_trait_use_forbidden_responses_for_permission_and_ownership_denials":0.19,"P\\Tests\\Unit\\Permissions\\GroupPermissionSessionInvalidationWiringTest::__pest_evaluable_it_invalidates_related_user_permission_and_auth_session_caches_when_group_permissions_change":0.064,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetDurationTest::__pest_evaluable_it_parses_advanced_target_duration_from_snake__case_fields":0.36,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetDurationTest::__pest_evaluable_it_parses_advanced_target_duration_from_camelCase_aliases":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetDurationTest::__pest_evaluable_it_normalizes_ENTIRE__DURATION_to_ignore_target__duration__every":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetDurationTest::__pest_evaluable_it_defaults_recurring_target__duration__every_to_1_when_omitted":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetDurationTest::__pest_evaluable_it_keeps_strict_legacy_mode_when_target__duration_is_invalid":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetMathTest::__pest_evaluable_it_computes_weekly_target_using_touched_ISO_weeks_for_March_2026":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetMathTest::__pest_evaluable_it_applies_target__duration__every_for_weekly_cadence":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetMathTest::__pest_evaluable_it_prorates_ENTIRE__DURATION_target_by_overlap_days":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetMathTest::__pest_evaluable_it_splits_advanced_target_by_override_weights_per_department":0,"P\\Tests\\Unit\\Goals\\GoalsCriteriaTargetMathTest::__pest_evaluable_it_preserves_legacy_target_behavior_when_target__duration_is_missing":0,"P\\Tests\\Unit\\Goals\\GoalsLegacyRendererScriptsTest::__pest_evaluable_it_keeps_legacy_monthly_renderer_script_green":0.466,"P\\Tests\\Unit\\Goals\\GoalsLegacyRendererScriptsTest::__pest_evaluable_it_keeps_legacy_department_daily_renderer_script_green":0.879,"P\\Tests\\Unit\\Goals\\GoalsOpenApiSpecTest::__pest_evaluable_it_documents_advanced_goals_target_duration_fields_in_openapi":0.07,"P\\Tests\\Unit\\Workfeed\\WorkfeedClientConformanceTest::__pest_evaluable_it_uses_company_scoped_workfeed_endpoints_and_raw_Authorization_header":0.006,"P\\Tests\\Unit\\Workfeed\\WorkfeedClientConformanceTest::__pest_evaluable_it_normalizes_documented_shift_query_parameters":0.005,"P\\Tests\\Unit\\Workfeed\\WorkfeedConfigRouteWiringTest::__pest_evaluable_it_registers_module_config_endpoints_for_workfeed":0.007,"P\\Tests\\Unit\\Workfeed\\WorkfeedRouteHelpersTest::__pest_evaluable_it_filters_request_parameters_down_to_the_allowed_workfeed_query_keys":0.001,"P\\Tests\\Unit\\Workfeed\\WorkfeedRouteWiringTest::__pest_evaluable_it_registers_employee_and_department_endpoints_for_the_workfeed_module":0.008,"P\\Tests\\Unit\\Workfeed\\WorkfeedRouteWiringTest::__pest_evaluable_it_registers_shift_endpoints_for_the_workfeed_module":0.008,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_calculates_workfeed_employee_hours_for_the_hour_slot_based_on_overlap":0.001,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_extracts_department_id_from_supported_workfeed_shift_shapes":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_normalizes_wrapped_workfeed_collections_from_common_response_keys":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineSchemaConformanceTest::__pest_evaluable_it_includes_a_date_key_in_department_weather_timeline_entries":0.006,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineSchemaConformanceTest::__pest_evaluable_it_documents_the_date_key_in_the_openapi_department_weather_timeline_schema":0.034,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineRangeTest::__pest_evaluable_it_builds_a_timeline_range_from_start_of_yesterday_to_end_of_today":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineRangeTest::__pest_evaluable_it_builds_a_timeline_range_relative_to_an_explicit_selected_date_override":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_calculates_workfeed_employee_hours_across_multiple_departments_for_one_hour_slot":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_normalizes_department_id_input_from_scalar_csv_and_nested_array_values":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineRangeTest::__pest_evaluable_it_builds_a_timeline_range_relative_to_explicit_date__from_and_date__to_override":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineRangeTest::__pest_evaluable_it_resolves_forecast_day_count_for_a_given_timeline_range_with_sane_limits":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_resolves_weather_coordinates_from_valid_coordinates_only":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_returns_null_weather_coordinates_when_all_department_locations_are_invalid":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_classifies_weatherapi_no_matching_location_errors_as_location_lookup_failures":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_does_not_classify_non_location_weatherapi_errors_as_location_lookup_failures":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_returns_an_empty_forecast_fallback_when_coordinates_are_unavailable":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_builds_timeline_entries_with_mostly__clear_weather_when_forecast_payload_is_empty":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherFallbackBehaviorTest::__pest_evaluable_it_wires_departments_weather_route_through_the_forecast_fallback_path":0.007,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_uses_sane_ttl_defaults_and_clamps_negative_ttl_to_zero":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_builds_deterministic_cache_keys_for_department_sets_and_timeline_ranges":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_builds_order_insensitive_cache_keys_for_equivalent_department_id_sets":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_falls_back_to_resolver_directly_when_cache_ttl_is_disabled":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTimelineRangeTest::__pest_evaluable_it_marks_non_started_slots_as_unknown_regardless_of_wash_hour_ratio":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherPreloadCronWiringTest::__pest_evaluable_it_registers_and_implements_cron_preloading_for_department_weather_cache":0.012,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_uses_sane_stale_ttl_defaults_and_clamps_stale_ttl_below_fresh_ttl":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_uses_sane_hot_activity_ttl_defaults_and_clamps_to_a_positive_value":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheRuntimeBehaviorTest::__pest_evaluable_it_serves_fresh_cached_payloads_without_invoking_the_resolver":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheRuntimeBehaviorTest::__pest_evaluable_it_serves_stale_cached_payloads_and_enqueues_a_refresh_signal":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheRuntimeBehaviorTest::__pest_evaluable_it_recomputes_and_rewrites_cache_payloads_when_stale_window_is_exceeded":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheRuntimeBehaviorTest::__pest_evaluable_it_records_hot_keys_order_insensitively_and_applies_preload_target_caps":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_normalizes_batched_wash_count_rows_into_hourly_slot_totals":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_wires_department_weather_route_to_use_batched_wash_aggregation":0.015,"P\\Tests\\Unit\\Orders\\AttachmentsListManyTest::__pest_evaluable_it_groups_attachments_by_object_id_and_preserves_empty_object_groups":0,"P\\Tests\\Unit\\Orders\\AttachmentsListManyTest::__pest_evaluable_it_keeps_listMany_payload_parity_with_list_for_one_object_id":0,"P\\Tests\\Unit\\Orders\\EconomicModuleOrdersBatchHelpersTest::__pest_evaluable_it_ensures_economic_module_rows_in_one_sanitized_batch":0,"P\\Tests\\Unit\\Orders\\EconomicModuleOrdersBatchHelpersTest::__pest_evaluable_it_returns_id_keyed_economic_module_payload_with_null_defaults":0,"P\\Tests\\Unit\\Orders\\OrdersRouteListBatchingWiringTest::__pest_evaluable_it_wires_GET__orders_through_batched_enrichment_and_stripe_snapshot_caching":0.116,"P\\Tests\\Unit\\Orders\\UsersCashierNamesBatchTest::__pest_evaluable_it_returns_cashier_names_from_cache_and_fetches_missing_ones_in_batch":0,"P\\Tests\\Unit\\Orders\\UsersCashierNamesBatchTest::__pest_evaluable_it_returns_deterministic_map_for_duplicate_and_unsorted_cashier_ids":0,"P\\Tests\\Unit\\Orders\\UsersCashierNamesBatchWiringTest::__pest_evaluable_it_implements_batched_cashier_name_lookup_with_cache_first_fallback_behavior":0.141,"P\\Tests\\Unit\\Orders\\UsersCashierNamesBatchWiringTest::__pest_evaluable_it_sanitizes_and_deduplicates_cashier_ids_before_batch_lookup":0.064,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_counts_overtime_minutes_when_a_shift_carries_an_extended_approval_end_timestamp":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_counts_unapproved_overtime_from_a_bounded_shift_updateTime_fallback":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_does_not_treat_late_unapproved_administrative_edits_as_overtime":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheHelpersTest::__pest_evaluable_it_changes_weather_timeline_cache_key_when_department_weather_targets_change":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_evaluates_healthy_degraded_and_unhealthy_statuses_from_configured_department_targets":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_returns_unknown_when_configured_targets_are_missing_for_department_status_evaluation":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_aggregates_multi_department_slot_statuses_using_worst_severity_and_unknown_fallback_rules":0.046,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsOpenApiSpecTest::__pest_evaluable_it_documents_department_weather_target_endpoints_and_reusable_schemas_in_openapi":0.008,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsOpenApiSpecTest::__pest_evaluable_it_documents_unknown_weather_status_behavior_when_targets_are_missing":0.01,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsRouteWiringTest::__pest_evaluable_it_registers_department_weather_target_routes_with_explicit_read_and_manage_permissions":0.006,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsRouteWiringTest::__pest_evaluable_it_persists_department_weather_targets_using_canonical_department_variable_keys":0.005,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_machine_relay_status_get_and_set_endpoints":0.006,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_batches_sequential_machine_relay_status_requests_into_a_single_Shelly_get_call":0.001,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_respects_the_1_request_per_second_Shelly_gate_for_back_to_back_requests":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_retries_missing_Shelly_status_payloads_until_relay_status_becomes_ready":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_times_out_with_a_clear_Shelly_readiness_error_when_payload_remains_missing":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_uses_direct_set_switch_and_seeds_cache_so_immediate_status_read_does_not_call_Shelly_get":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_self_serve_lane_gate_open_endpoint":0.004,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_in_progress_self_serve_wash_details_endpoint":0.003,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_adds_vehicle_type_product_on_STOP_when_program_selector_is_on__then_turns_off_cleaner_machine_and_selector_relays":0.132,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_skips_vehicle_type_product_add_when_program_selector_is_off_and_only_disables_configured_relays":0.001,"P\\Tests\\Unit\\Selfserve\\DepartmentSelfServeEnabledRelaySyncWiringTest::__pest_evaluable_it_syncs_lane_relay_states_when_department_self_serve_enabled_flag_changes":0.034,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_supports_hard_relay_set_even_when_lane_status_is_CLOSED":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStartCleanerRelayWiringTest::__pest_evaluable_it_enables_cleaner_relay_on_wash_start_command_and_webhook_flow":0.016,"P\\Tests\\Unit\\Selfserve\\SelfserveWashCompletionRelayWiringTest::__pest_evaluable_it_forces_machine_relay_off_when_a_self_serve_wash_session_is_completed":0.003,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_opens_exit_port_by_switching_relay__in__id_on":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_opens_entrance_port_by_switching_relay__out__id_on":0,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_in_progress_self_serve_wash_start_and_machine_relay_fields":0.006,"P\\Tests\\Unit\\Selfserve\\SelfserveConditionEvaluatorTest::__pest_evaluable_it_evaluates_typed_task_gates_with_strict_semantics":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_validates_typed_task_gates_for_known_references":0.001,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_fails_validation_when_typed_task_gates_reference_unknown_entities":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_self_serve_config_draft_publish_rollback_lifecycle_endpoints":0.004,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_keeps_legacy_self_serve_CRUD_routes_syncing_canonical_drafts":0.012,"P\\Tests\\Unit\\Selfserve\\SelfserveStartCleanerRelayWiringTest::__pest_evaluable_it_keeps_cleaner_relay_enable_wired_into_machine_relay_start_paths":0.01,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_stores_config_json_with_apostrophes_without_violating_json_constraints":0.277,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_encodes_config_json_payloads_with_apostrophes_before_persistence":0,"P\\Tests\\Unit\\Selfserve\\SelfserveWashCompletionRelayWiringTest::__pest_evaluable_it_forces_machine_and_cleaner_relays_off_when_a_self_serve_wash_session_is_completed":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_vehicle_type_override_into_self_serve_preview_and_synchronization_routes":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_uses_local_demo_responses_and_skips_Shelly_cloud_calls_for_demo_relay_ids":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_returns_default_OFF_status_for_demo_relay_ids_without_Shelly_lookups":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_excludes_demo_relay_ids_from_Shelly_status_batch_payloads":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_keeps_demo_relay_gate_open_queued_but_skips_Shelly_switch_calls":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_allowed_services_route_through_machine_relay_visibility_sync":0.004,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_enables_MACHINE_relay_when_MACHINE_is_visible_in_allowed_services":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_turns_MACHINE_relay_off_immediately_when_MACHINE_is_not_visible":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_does_not_enable_MACHINE_relay_when_allowEnable_is_false_even_if_MACHINE_is_visible":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_is_a_safe_no_op_when_MACHINE_relay_is_not_configured":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRelayVisibilitySyncWiringTest::__pest_evaluable_it_synchronizes_machine_relay_from_visible_services_during_session_synchronization":0.011,"P\\Tests\\Unit\\Selfserve\\SelfserveRelayVisibilitySyncWiringTest::__pest_evaluable_it_adds_explicit_relay_disable_session_helper_for_visibility_driven_OFF_transitions":0.006,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_self_serve_property_gate_command_permissions":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneCommandEnumTest::__pest_evaluable_it_parses_property_gate_lane_commands":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePropertyGateCommandTest::__pest_evaluable_it_opens_entrance_gate_for_OPEN__PROPERTY__ACCESS__GATE_command":0.196,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePropertyGateCommandTest::__pest_evaluable_it_opens_exit_gate_for_OPEN__PROPERTY__EXIT__GATE_command":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePropertyGateCommandTest::__pest_evaluable_it_blocks_property_gate_commands_when_department_self_serve_is_disabled":0.033,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePropertyGateCommandTest::__pest_evaluable_it_throws_a_clear_error_when_entrance_gate_is_missing_for_property_access_command":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePropertyGateCommandTest::__pest_evaluable_it_wraps_low_level_gate_errors_for_property_access_command_failures":0.003,"P\\Tests\\Unit\\Selfserve\\SelfserveWashFlowMachineAllowedWiringTest::__pest_evaluable_it_reads_machine_allowed_state_from_session_summary_payload_safely":0.007,"P\\Tests\\Unit\\Selfserve\\DbObjectPaginationLegacyColumnCompatibilityTest::__pest_evaluable_it_guards_pagination_against_searchable_fields_missing_from_legacy_schemas":0.05,"P\\Tests\\Unit\\Selfserve\\SelfserveSchemaBootstrapCompatibilityTest::__pest_evaluable_it_adds_dynamic__images__vehicle__type_column_for_legacy_selfserve_wash_session_task_schemas":0.003,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneInvoiceIncludedMinutesTest::__pest_evaluable_it_computes_billable_minutes_when_elapsed_minutes_exceed_included_minutes":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneInvoiceIncludedMinutesTest::__pest_evaluable_it_computes_zero_billable_minutes_when_elapsed_minutes_equal_included_minutes":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneInvoiceIncludedMinutesTest::__pest_evaluable_it_computes_zero_billable_minutes_when_included_minutes_exceed_elapsed_minutes":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneInvoiceIncludedMinutesTest::__pest_evaluable_it_handles_zero_and_low_elapsed_wash_time_edge_cases":0,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_self_serve_machine_wash_included_minutes_in_config_schemas":0.006,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_machine_wash_included_minutes_into_self_serve_module_config":0.004,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_blocks_explicit_relay_writes_when_department_self_serve_is_disabled":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_returns_disabled_no_op_from_machine_relay_visibility_sync_when_department_self_serve_is_disabled":0,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_property_gate_lane_commands_and_sanitized_gate_failure_responses":0.005,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_fast_when_workspace_id_is_missing_for_gate_calls":0.022,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_fast_when_channel_id_is_missing_for_gate_calls":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_with_terminal_status_details_when_call_never_reaches_accepted_state":0.067,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_hangs_up_when_call_reaches_accepted_state":0.001,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_hangs_up_immediately_when_status_transitions_to_accepted":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_maps_legacy_timeout_option_to_documented_ringTimeout_payload_field":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_clamps_derived_ringTimeout_to_Bird_documented_max_when_gate_timeout_is_high":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_passes_documented_hangup_cause_when_provided":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_drops_unsupported_hangup_cause_values_from_request_payload":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_fast_when_workspace_id_is_missing_for_gate_flash_calls":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_creates_gate_flash_call_with_documented_ringTimeout_payload":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_polls_flash_call_and_succeeds_once_accepted":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_fails_flash_gate_flow_when_terminal_failure_status_is_returned":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_does_not_fallback_to_regular_gate_call_when_flash_succeeds":0,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_falls_back_to_regular_gate_call_when_flash_fails":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePropertyGateCommandTest::__pest_evaluable_it_wraps_low_level_gate_errors_for_property_exit_command_failures":0.002,"P\\Tests\\Unit\\Bird\\BirdGateCallFlowTest::__pest_evaluable_it_falls_back_to_regular_gate_call_when_flash_caller_id_is_not_confirmed":0,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_maps_voice_call_facade_methods_to_documented_endpoints_and_methods":0.024,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_maps_recordings_insights_log_and_flash_methods_to_documented_resources":0.001,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_encodes_path_segments_before_building_outbound_endpoints":0,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_serializes_query_parameters_for_GET_transport_requests":0,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_maps_4xx_and_5xx_transport_failures_into_informative_exceptions":0,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_returns_raw_payload_for_malformed_JSON_responses_and_null_for_empty_responses":0.007,"P\\Tests\\Unit\\Bird\\BirdOpenApiSpecTest::__pest_evaluable_it_documents_all_Bird_voice_call_parity_endpoints_including_gather_recordings_insights_and_log":0.008,"P\\Tests\\Unit\\Bird\\BirdOpenApiSpecTest::__pest_evaluable_it_documents_flash_hangup_endpoint_and_marks_end_alias_as_deprecated":0.006,"P\\Tests\\Unit\\Bird\\BirdOpenApiSpecTest::__pest_evaluable_it_defines_request_and_response_schemas_for_Bird_call_command_recording_insight_log_and_flash_payloads":0.009,"P\\Tests\\Unit\\Bird\\BirdRequestValidationTest::__pest_evaluable_it_accepts_valid_payloads_for_create_and_nested_call_flow_commands":0.002,"P\\Tests\\Unit\\Bird\\BirdRequestValidationTest::__pest_evaluable_it_rejects_unknown_fields_and_invalid_enum_values_in_strict_schemas":0.051,"P\\Tests\\Unit\\Bird\\BirdRequestValidationTest::__pest_evaluable_it_supports_CSV_normalization_in_schema_validation_for_log_filters":0,"P\\Tests\\Unit\\Bird\\BirdRequestValidationTest::__pest_evaluable_it_validates_flash_hangup_payloads_for_both_supported_request_shapes":0,"P\\Tests\\Unit\\Bird\\BirdRequestValidationTest::__pest_evaluable_it_fails_before_forward_step_when_strict_validation_fails_and_forwards_when_valid":0,"P\\Tests\\Unit\\Bird\\BirdRouteWiringTest::__pest_evaluable_it_registers_full_Bird_voice_call_route_surface_with_strict_validation_and_permissions":0.04,"P\\Tests\\Unit\\Bird\\BirdRouteWiringTest::__pest_evaluable_it_registers_flash_hangup_endpoint_and_keeps_end_alias_wired_as_compatibility_path":0.033,"P\\Tests\\Unit\\Bird\\BirdRouteWiringTest::__pest_evaluable_it_keeps_Bird_number_and_webhook_routes_intact":0.063,"P\\Tests\\Unit\\Bird\\BirdModuleClientMappingTest::__pest_evaluable_it_handles_malformed_and_empty_response_bodies_without_throwing_transport_errors":0.005,"P\\Tests\\Unit\\Selfserve\\DepartmentGatesRelaysRouteWiringTest::__pest_evaluable_it_validates_department_gate_config_on_both_create_and_update_routes":0.059,"P\\Tests\\Unit\\Selfserve\\SelfserveSchemaBootstrapCompatibilityTest::__pest_evaluable_it_adds_wash__started__at_column_for_legacy_selfserve_wash_session_schemas":0.002,"P\\Tests\\Unit\\Selfserve\\SelfserveWashStartedAtWiringTest::__pest_evaluable_it_stores_wash__started__at_in_self_serve_wash_sessions_when_machine_start_is_triggered":0.004,"P\\Tests\\Unit\\Selfserve\\SelfserveWashStartedAtWiringTest::__pest_evaluable_it_passes_lane_wash_start_time_into_the_session_machine_start_marker":0.01,"P\\Tests\\Unit\\Selfserve\\SelfserveWashStartedAtWiringTest::__pest_evaluable_it_resolves_in_progress_wash__started__at_from_session_with_compatibility_fallbacks":0.004,"P\\Tests\\Unit\\Bird\\BirdPayloadClassesTest::__pest_evaluable_it_normalizes_say_payload_and_applies_hangup_default":0.596,"P\\Tests\\Unit\\Bird\\BirdPayloadClassesTest::__pest_evaluable_it_normalizes_log_query_csv_and_integer_fields":0,"P\\Tests\\Unit\\Bird\\BirdPayloadClassesTest::__pest_evaluable_it_normalizes_nested_create_call_payload_sections_and_drops_unknown_keys":0,"P\\Tests\\Unit\\Bird\\BirdPayloadClassesTest::__pest_evaluable_it_keeps_gather_nested_say_payload_unchanged_when_hangup_is_omitted":0,"P\\Tests\\Unit\\Bird\\BirdPayloadClassesTest::__pest_evaluable_it_supports_no_body_and_flash_hangup_payload_normalization":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_respects_the_2_second_Shelly_gate_for_back_to_back_requests":0.023,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_enforces_a_2_second_gap_between_sequential_switch_requests_used_by_wash_start_and_stop_flows":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_keeps_back_to_back_get_switch_requests_ordered_through_the_relay_controller":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_executes_sequential_switch_requests_used_by_wash_start_and_stop_flows":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_does_not_print_Shelly_switch_responses_to_output":0,"P\\Tests\\Unit\\Selfserve\\ShellyGlobalRateLimitWiringTest::__pest_evaluable_it_enforces_a_global_2_second_Shelly_gate_in_sendPostRequest":0.006,"P\\Tests\\Unit\\Selfserve\\ShellyGlobalRateLimitWiringTest::__pest_evaluable_it_uses_Redis_NX_PX_semantics_for_cross_request_Shelly_rate_limiting":0.006,"P\\Tests\\Unit\\Selfserve\\ShellyGlobalRateLimitBehaviorTest::__pest_evaluable_it_enforces_the_2_second_Shelly_gate_across_separate_request_contexts":0,"P\\Tests\\Unit\\Selfserve\\ShellyGlobalRateLimitBehaviorTest::__pest_evaluable_it_does_not_delay_when_the_Shelly_gate_is_already_expired":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_adds_vehicle_type_product_on_STOP_when_program_selector_relay_is_online__then_turns_off_cleaner_and_machine_relays":0.62,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_skips_vehicle_type_product_add_when_program_selector_relay_is_offline_and_only_disables_configured_relays":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_bills_primary_product_when_selector_relay_is_online_even_if_relay_output_is_off":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneInvoiceModeBillingTest::__pest_evaluable_it_bills_manual_self_serve_stop_using_full_elapsed_minutes_without_included_minute_reduction":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneInvoiceModeBillingTest::__pest_evaluable_it_keeps_included_minute_reduction_for_automatic_mode":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_sends_Slack_on_first_dtmf_input_captured_within_the_300_second_window":0.005,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_does_not_send_Slack_when_dtmf_input_repeats_without_change":0.008,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_sends_Slack_again_when_dtmf_input_changes_within_the_window":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_keeps_gathering_and_does_not_hang_up_before_300_seconds_have_elapsed":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_announces_timeout_waits_10_seconds_and_sends_hangup_once_at_or_after_300_seconds":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_keeps_polling_until_terminal_status_and_only_then_finalizes":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_finalizes_immediately_when_webhook_payload_is_already_terminal_before_timeout_logic":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_does_not_duplicate_timeout_or_hangup_actions_across_retries_and_lock_contention":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_keeps_state_when_polling_fails_so_completion_is_not_falsely_reported":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_sends_Slack_when_dtmf_input_repeats_without_change":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_answers_immediately_once_and_does_not_re_answer_on_subsequent_webhook_retries":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_configures_gather_with_retry_loop_semantics_until_input_is_entered":0.004,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_sends_Slack_when_gather_result_provides_keys_field_instead_of_dtmf":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_sends_Slack_when_gather_conditions_variable_keys_carries_the_entered_value":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_configures_gather_to_check_every_2_seconds_with_retry_loop_semantics_until_input_is_entered":0,"P\\Tests\\Unit\\Users\\UsersAutomaticGetTargetUserFromRequestTest::__pest_evaluable_it_ignores_non_numeric_customer__number_values_from_request":0.042,"P\\Tests\\Unit\\Users\\UsersAutomaticGetTargetUserFromRequestTest::__pest_evaluable_it_casts_numeric_customer__number_strings_to_int_before_lookup":0,"P\\Tests\\Unit\\Users\\UsersAutomaticGetTargetUserFromRequestTest::__pest_evaluable_it_prefers_user__id_when_both_user__id_and_customer__number_are_valid":0,"P\\Tests\\Unit\\Users\\UsersAutomaticGetTargetUserFromRequestTest::__pest_evaluable_it_falls_back_to_customer__number_when_user__id_is_invalid":0,"P\\Tests\\Unit\\Users\\UsersAutomaticGetTargetUserFromRequestTest::__pest_evaluable_it_normalizes_request_identifiers_before_user_lookups":0.005,"P\\Tests\\Unit\\Users\\UsersAutomaticGetTargetUserFromRequestTest::__pest_evaluable_it_rejects_non_positive_and_non_digit_request_values":0.003,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_uses_pagination_results_when_present":0.006,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_falls_back_to_collection_count_when_pagination_metadata_is_missing":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_returns_zero_when_neither_pagination_nor_collection_is_available":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_counts_object_based_collections_when_pagination_metadata_is_missing":0,"P\\Tests\\Unit\\Invoicing\\EconomicPaymentTermsRouteCollectionExtractionTest::__pest_evaluable_it_uses_collection_when_present":0,"P\\Tests\\Unit\\Invoicing\\EconomicPaymentTermsRouteCollectionExtractionTest::__pest_evaluable_it_falls_back_to_paymentTerms_when_collection_is_missing":0,"P\\Tests\\Unit\\Invoicing\\EconomicPaymentTermsRouteCollectionExtractionTest::__pest_evaluable_it_supports_top_level_array_payloads":0,"P\\Tests\\Unit\\Invoicing\\EconomicPaymentTermsRouteCollectionExtractionTest::__pest_evaluable_it_returns_an_empty_array_when_no_known_collection_shape_exists":0,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_uses_grant__2_in_economic__endpoint__t_when_explicitly_requested_and_configured":0,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_falls_back_to_grant__1_in_economic__endpoint__t_when_grant__2_is_missing":0.003,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_throws_for_missing_required_primary_grant_in_economic__endpoint__t":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_throws_for_missing_app_secret_in_economic__endpoint__t":0,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_uses_grant__2_in_legacy_economic__m_when_available":0,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_falls_back_to_grant__1_in_legacy_economic__m_when_grant__2_is_missing":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_throws_for_missing_required_primary_grant_in_legacy_economic__m":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_throws_for_missing_app_secret_in_legacy_economic__m":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_uses_unfiltered_total_when_no_customer_filters_are_active":0.004,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_uses_filtered_total_when_search_is_active":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_uses_filtered_total_when_barred_filter_is_active":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_treats__null__search_and_barred_values_as_no_filter":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRoutePaginationFallbackTest::__pest_evaluable_it_falls_back_to_filtered_total_when_unfiltered_total_is_missing":0,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRouteEconomicFailureHandlingTest::__pest_evaluable_it_maps_e_conomic_integration_failures_in_customer_listing_to_explicit_502_responses":0.007,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRouteEconomicFailureHandlingTest::__pest_evaluable_it_logs_LIST__CUSTOMERS_success_only_after_pagination_and_customer_mapping_are_completed":0.005,"P\\Tests\\Unit\\Invoicing\\CustomerSearchRouteEconomicFailureHandlingTest::__pest_evaluable_it_guards_against_malformed_customer_list_payloads_before_calling_paginate":0.006,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_throws_deterministic_upstream_exceptions_for_non_2xx_endpoint_trait_responses":0,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_throws_deterministic_upstream_exceptions_for_non_2xx_legacy_economic__m_responses":0,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_returns_raw_payload_unchanged_on_successful_HTTP_statuses":0,"P\\Tests\\Unit\\Invoicing\\EconomicCustomersListResponseValidationTest::__pest_evaluable_it_accepts_valid_list_payloads_that_include_collection_and_pagination_results":0,"P\\Tests\\Unit\\Invoicing\\EconomicCustomersListResponseValidationTest::__pest_evaluable_it_throws_when_pagination_is_missing_from_the_response_payload":0,"P\\Tests\\Unit\\Invoicing\\EconomicCustomersListResponseValidationTest::__pest_evaluable_it_throws_when_upstream_responds_with_an_error_shaped_payload":0,"P\\Tests\\Unit\\Router\\RouterThrowableHandlingTest::__pest_evaluable_it_catches_throwables_during_auto_route_loading_and_maps_them_to_internal_server_errors":0.039,"P\\Tests\\Unit\\Users\\EconomicCustomerZeroHandlingTest::__pest_evaluable_it_does_not_request_e_conomic_customer_data_when_customer_number_is_zero":0.004,"P\\Tests\\Unit\\Users\\EconomicCustomerZeroHandlingTest::__pest_evaluable_it_short_circuits_e_conomic_customer_lookup_for_non_positive_customer_numbers":0.003,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueRouteRegistrationTest::__pest_evaluable_it_registers_queued_economic_invoice_endpoints_in_economicInvoiceRoute":0.013,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueRouteRegistrationTest::__pest_evaluable_it_registers_collected_invoice_queue_endpoints_in_orderInvoicesRoute":0.011,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueSchemaBootstrapTest::__pest_evaluable_it_defines_economic_transfer_queue_jobs_schema_bootstrap_table_and_tracking_columns":0.011,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueSchemaBootstrapTest::__pest_evaluable_it_provides_queue_processor_class_constants_and_processing_entrypoint":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueCronIntegrationTest::__pest_evaluable_it_registers_economic_transfer_queue_cron_task_and_handler":0.007,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_economic_transfer_queue_paths_in_openapi":0.013,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_economic_transfer_queue_schemas_in_openapi":0.01,"P\\Tests\\Unit\\Invoicing\\EconomicTransferOrderItemSkipTest::__pest_evaluable_it_skips_order_items_with_zero_quantity_for_e_conomic_export":0,"P\\Tests\\Unit\\Invoicing\\EconomicTransferOrderItemSkipTest::__pest_evaluable_it_skips_order_items_with_zero_price_for_e_conomic_export":0.006,"P\\Tests\\Unit\\Invoicing\\EconomicTransferOrderItemSkipTest::__pest_evaluable_it_keeps_positive_quantity_and_price_items_billable_for_e_conomic_export":0,"P\\Tests\\Unit\\Invoicing\\EconomicTransferOrderItemSkipTest::__pest_evaluable_it_wires_zero_cost_and_zero_quantity_skip_guard_into_transfer_line_builder":0.048,"P\\Tests\\Unit\\Invoicing\\EconomicInvoiceDraftZeroItemSkipWiringTest::__pest_evaluable_it_skips_zero_cost_and_zero_quantity_items_in_legacy_economic_draft_helper":0.023,"P\\Tests\\Unit\\Invoicing\\EconomicTransferOrderItemSkipTest::__pest_evaluable_it_skips_malformed_order_item_payloads_for_e_conomic_export_safety":0,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueHardeningTest::__pest_evaluable_it_hardens_transfer_queue_with_type_validation_retry_caps_and_stale_lock_recovery":0.043,"P\\Tests\\Unit\\Router\\AutoloadRedisCacheValidationTest::__pest_evaluable_it_validates_cached_autoload_paths_before_returning_and_clears_stale_cache_entries":0.008,"P\\Tests\\Unit\\Orders\\OrdersRegistrationDateRangeQueryTest::__pest_evaluable_it_applies_created__at_bounds_directly_in_SQL_when_filtering_orders_by_registration_number":0.001,"P\\Tests\\Unit\\Orders\\OrdersRegistrationDateRangeQueryTest::__pest_evaluable_it_rejects_an_inverted_date_range_for_registration_lookups":0,"P\\Tests\\Unit\\Orders\\OrdersRegistrationDateRangeQueryTest::__pest_evaluable_it_returns_early_when_registration_number_is_blank":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueCronIntegrationTest::__pest_evaluable_it_wires_queue_worker_class_loading_for_CLI_queue_action":0.006,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_guards_all_economic_invoice_queue_endpoints_before_constructing_queue_service":0.012,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_guards_all_collected_invoice_queue_endpoints_before_constructing_queue_service":0.011,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_uses_a_consistent_unavailable_service_contract_for_missing_queue_dependencies":0.009,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_gracefully_handles_unavailable_queue_dependencies_on_collected_invoice_queue_endpoints":0.005,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceEconomicQueueResponseContractTest::__pest_evaluable_it_returns_the_queue_job_id_in_POST__collected_invoices_economic_enqueue_response":0.204,"P\\Tests\\Unit\\Invoicing\\UserCollectedInvoiceUpdateRouteValidationTest::__pest_evaluable_it_requires_id_and_at_least_one_mutable_field_for_PUT__collected_invoices_in_user_route":0.019,"P\\Tests\\Unit\\Invoicing\\UserCollectedInvoiceUpdateRouteValidationTest::__pest_evaluable_it_supports_independent_po__number_and_closed__at_updates_for_PUT__collected_invoices_in_user_route":0.008,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceEconomicQueueResponseContractTest::__pest_evaluable_it_returns_queued_contract_for_POST__collected_invoices_stripe_book":0.002,"P\\Tests\\Unit\\Invoicing\\EconomicDraftQueueOnlyRouteBehaviorTest::__pest_evaluable_it_keeps_order_draft_export_route_queue_only":0.006,"P\\Tests\\Unit\\Invoicing\\EconomicDraftQueueOnlyRouteBehaviorTest::__pest_evaluable_it_keeps_collected_invoice_draft_producing_routes_queue_only_in_orderInvoicesRoute":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_guards_collected_invoice_and_stripe_draft_producing_endpoints_before_constructing_queue_service":0.004,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_unavailable_queue_dependency_responses_for_async_economic_transfer_endpoints":0.007,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueuePayloadValidationTest::__pest_evaluable_it_normalizes_order_payloads_to_strict_positive_integer_ids":0,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueuePayloadValidationTest::__pest_evaluable_it_normalizes_collected_invoice_payload_booleans_to_strict_bool_values":0,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueuePayloadValidationTest::__pest_evaluable_it_rejects_invalid_transfer_payloads_before_enqueue_write_attempts":0,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_processes_queued_transfer_jobs_to_completion":0.016,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_deduplicates_active_jobs_per_transfer_target":0.016,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_rejects_invalid_payloads_without_inserting_queue_rows":0.013,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_supports_fail_retry_and_reprocess_lifecycle_transitions":0.014,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_keeps_queue_guards_on_economic_invoice_queue_status_and_retry_routes":0.01,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_supports_synchronous_fallback_branches_before_queue_enqueue_on_economic_invoice_export_routes":0.005,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_keeps_queue_status_endpoints_guarded_while_collected_invoice_export_routes_support_synchronous_fallback":0.01,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueAvailabilityGuardTest::__pest_evaluable_it_uses_a_consistent_unavailable_service_contract_for_queue_status_lifecycle_endpoints":0.057,"P\\Tests\\Unit\\Invoicing\\EconomicDraftQueueOnlyRouteBehaviorTest::__pest_evaluable_it_supports_synchronous_fallback_and_queued_processing_for_order_draft_export_route":0.006,"P\\Tests\\Unit\\Invoicing\\EconomicDraftQueueOnlyRouteBehaviorTest::__pest_evaluable_it_supports_synchronous_fallback_and_queued_processing_for_collected_invoice_draft_producing_routes":0.006,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceEconomicQueueResponseContractTest::__pest_evaluable_it_documents_both_synchronous_fallback_and_queued_response_contracts_for_POST__collected_invoices_economic":0.165,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceEconomicQueueResponseContractTest::__pest_evaluable_it_documents_both_synchronous_fallback_and_queued_response_contracts_for_POST__collected_invoices_stripe_book":0.018,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_200_fallback_plus_202_queue_contracts_for_export_endpoints_and_keeps_503_on_queue_lifecycle_endpoints":0.009,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueRouteHardeningTest::__pest_evaluable_it_returns_pagination_metadata_and_strict_status_handling_for_collected_invoice_queue_list":0.006,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueRouteHardeningTest::__pest_evaluable_it_enforces_retry_constraints_for_collected_invoice_queue_jobs_before_retry_execution":0.008,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_collected_queue_list_metadata_and_strict_status_filter_enum_in_openapi":0,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_builds_a_completed_collected_invoice_queue_summary_from_payload_and_result":0.029,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_prefers_queue_error_messages_for_failed_jobs_and_keeps_null_safe_outcome_fields":0,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_uses_deterministic_status_message_fallback_when_no_explicit_message_exists":0,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_handles_object_payload_result_values_and_malformed_fields_without_throwing":0,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueWorkerTickRouteTest::__pest_evaluable_it_ticks_collected_invoice_transfer_queue_from_queue_list_and_status_routes":0.008,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueWorkerTickRouteTest::__pest_evaluable_it_ticks_order_transfer_queue_from_draft_and_invoice_status_routes":0.009,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodQueueOverlayTest::__pest_evaluable_it_marks_queued_transactions_and_clears_requires__action_when_all_actionable_work_is_already_queued":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodQueueOverlayTest::__pest_evaluable_it_keeps_requires__action_true_when_a_customer_still_has_unqueued_actionable_transactions":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodQueueOverlayTest::__pest_evaluable_it_blocks_fixed_pricing_or_subscription_customers_with_no_transactions_when_a_relevant_queue_job_exists":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerCommandQueueTest::__pest_evaluable_it_queues_admin_commands__exposes_agent_poll_result_handlers__and_keeps_heartbeats_from_mutating_discovery_state":0.003,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerCommandQueueTest::__pest_evaluable_it_defines_the_dispatchable_gateway_guard_on_the_loaded_manager_class":0.009,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_effective_gateway_status_from_heartbeat_freshness with data set \"dataset \"recent heartbeat stays online\"\"":0.001,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_effective_gateway_status_from_heartbeat_freshness with data set \"dataset \"late heartbeat degrades after one minute\"\"":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_effective_gateway_status_from_heartbeat_freshness with data set \"dataset \"stale heartbeat goes offline after five minutes\"\"":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_effective_gateway_status_from_heartbeat_freshness with data set \"dataset \"explicit offline reports stay offline even when heartbeat is fresh\"\"":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_effective_gateway_status_from_heartbeat_freshness with data set \"dataset \"missing heartbeat is treated as offline\"\"":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_marks_ready_discovery_as_stale_when_the_gateway_heartbeat_has_expired":0.727,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayRouteWiringTest::__pest_evaluable_it_registers_the_edge_gateway_management_REST_endpoints":0.021,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayRouteWiringTest::__pest_evaluable_it_registers_public_installer__claim__heartbeat__command_polling__and_internal_broker_auth_endpoints":0.004,"P\\Tests\\Unit\\Selfserve\\EdgeBrokerClientConfigTest::__pest_evaluable_it_uses_the_same_default_broker_url_and_shared_secret_fallback_as_the_docker_stack":0.267,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_install_script_urls_with_forwarded_https_scheme_when_proxied":0.336,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_appends_forwarded_ports_when_the_forwarded_host_omits_them":0.28,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_infers_https_for_the_staging_api_host_when_only_the_https_port_is_present":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_prefers_EDGE__PUBLIC__API__URL_when_explicitly_configured":1.003,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_normalizes_public_broker_overrides_to_https_and_browser_shell_urls_to_wss":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_keeps_localhost_broker_overrides_on_plain_http_and_ws_for_local_development":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_includes_node_pty_build_prerequisites_in_the_generated_install_script":0.007,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_prefers_EDGE__PUBLIC__API__URL_when_explicitly_configured_and_derives_the_broker_from_the_api_origin":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_normalizes_public_broker_overrides_to_https__strips_paths__and_browser_shell_urls_to_wss":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_merges_incoming_heartbeat_metadata_with_existing_gateway_metadata":0.006,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayRouteWiringTest::__pest_evaluable_it_registers_the_v2_operator_facing_edge_gateway_routes":0.008,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayRouteWiringTest::__pest_evaluable_it_registers_PHP_edge_agent_routes_for_operations_and_legacy_relay_command_polling":0.027,"P\\Tests\\Unit\\Selfserve\\EdgeGatewaySchemaBootstrapTest::__pest_evaluable_it_defines_the_v2_edge_gateway_schema_bootstrap_tables":0.006,"P\\Tests\\Unit\\Selfserve\\EdgeGatewaySchemaBootstrapTest::__pest_evaluable_it_stores_operation_metadata_and_event_timelines_for_management_workflows":0.005,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_builds_the_installer_around_the_PHP_agent_artifacts_and_management_polling_config":0.011,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_exposes_update_payload__credential_rotation__and_operation_endpoints_without_shell_transport_wiring":0.013,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerCommandQueueTest::__pest_evaluable_it_keeps_relay_dispatch_and_discovery_queueing_on_the_edge_gateway_manager":0.02,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerCommandQueueTest::__pest_evaluable_it_loads_relay_command_helpers_on_the_manager_and_gateway_operations_on_the_dedicated_service":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_derives_relay_fallback_and_transport_health_details_without_shell_or_update_runtime_state":0.837,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_resolves_edge_agent_artifacts_from_the_source_tree_layout":0.445,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_includes_the_container_mounted_artifact_directory_as_a_candidate":0.021,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_reads_install_artifacts_through_the_shared_locator":0.019,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_builds_update_payloads_with_checksums_from_resolved_artifact_paths":2.276,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_resolves_edge_agent_artifacts_from_a_supported_runtime_layout":0.273,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_install_script_urls_with_the_PHP_agent_artifacts_and_forwarded_https_scheme":0.001,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_explains_the_legacy_dist_mount_mismatch_when_php_artifacts_are_unavailable":0.005,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_prioritizes_mounted_and_baked_in_artifact_directories_before_repo_fallbacks":0.022,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_falls_back_to_baked_in_artifacts_when_the_mount_path_is_absent":0.013,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayArtifactLocatorTest::__pest_evaluable_it_prioritizes_router_resources_before_mounted_and_baked_in_artifact_directories":0,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_lists_orders_for_an_admin_scoped_user_and_limits_the_results_to_the_permitted_departments":1.109,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_lists_only_the_targeted_customer_orders_for_subuser_sessions":2.935,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_returns_the_current_auth_and_permission_failures_when_order_listing_is_not_allowed":1.375,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_creates_orders_through_the_real_endpoint":0.982,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_order_creation_requests":3.423,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_updates_orders_through_the_primary_endpoint":1.13,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_reassigns_invoice_collections_when_changing_an_order_across_the_draft_customer_boundary":1.615,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_supports_legacy_field_value_metadata_updates_through_the_primary_endpoint":5.592,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_updates_through_the_primary_order_endpoint":1.29,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_updates_orders_through_the_legacy_alias_endpoint":1.517,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_supports_legacy_field_value_metadata_updates_through_the_alias_endpoint":4.136,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_updates_through_the_legacy_alias_endpoint":0.512,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_unsupported_legacy_field_value_updates_on_both_update_endpoints":0.965,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_deletes_orders_through_the_real_endpoint":1.045,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_rejects_invalid_order_delete_requests":1.64,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_regenerates_attached_wash_certificates_when_certificate_metadata_changes_through_the_primary_endpoint":3.683,"P\\Tests\\Api\\OrdersApiTest::__pest_evaluable_it_regenerates_attached_wash_certificates_for_legacy_field_value_updates_through_the_alias_endpoint":3.194,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayOperationLegacySchemaCompatibilityTest::__pest_evaluable_it_keeps_the_legacy_operation__type_column_compatible_with_v2_operation_queueing":0.022,"P\\Tests\\Unit\\Bookings\\OrderBookingsListCacheTest::__pest_evaluable_it_uses_sane_ttl_defaults_and_clamps_negative_ttl_to_zero":0,"P\\Tests\\Unit\\Bookings\\OrderBookingsListCacheTest::__pest_evaluable_it_builds_deterministic_keys_for_equivalent_contexts":0,"P\\Tests\\Unit\\Bookings\\OrderBookingsListCacheTest::__pest_evaluable_it_stores_and_retrieves_full_order_bookings_payloads":0.001,"P\\Tests\\Unit\\Bookings\\OrderBookingsListCacheTest::__pest_evaluable_it_ignores_malformed_cached_payloads_and_clears_order_bookings_list_caches":0.001,"P\\Tests\\Unit\\Bookings\\OrderBookingsRouteCacheWiringTest::__pest_evaluable_it_caches_the_paginated_order_bookings_list_response_and_invalidates_it_on_booking_changes":0.051,"P\\Tests\\Unit\\Bookings\\VehicleSearchBookedMetadataRouteContractTest::__pest_evaluable_it_keeps_booked_vehicle_search_metadata_aligned_with_filtered_pending_order_bookings":0.052,"P\\Tests\\Unit\\Bookings\\OrderBookingsCountsCacheTest::__pest_evaluable_it_uses_sane_ttl_defaults_and_clamps_negative_ttl_to_zero":0.001,"P\\Tests\\Unit\\Bookings\\OrderBookingsCountsCacheTest::__pest_evaluable_it_builds_deterministic_keys_for_equivalent_count_contexts":0.039,"P\\Tests\\Unit\\Bookings\\OrderBookingsCountsCacheTest::__pest_evaluable_it_stores_and_retrieves_normalized_booking_counts":0,"P\\Tests\\Unit\\Bookings\\OrderBookingsCountsCacheTest::__pest_evaluable_it_ignores_malformed_payloads_and_clears_order_bookings_count_caches":0,"P\\Tests\\Unit\\Bookings\\OrderBookingsCountsRouteWiringTest::__pest_evaluable_it_adds_a_cached_order_bookings_counts_endpoint_and_invalidates_it_on_booking_changes":0.04,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayFleetUsageStatisticsTest::__pest_evaluable_it_summarizes_fleet_usage_statistics_for_the_dashboard_landing_view":0,"P\\Tests\\Api\\ApiCoverageManifestTest::__pest_evaluable_it_keeps_every_selected_API_operation_covered_by_happy_path_and_failure_tests":0.271,"P\\Tests\\Api\\ApiCoverageManifestTest::__pest_evaluable_it_keeps_the_OpenAPI_manifest_entries_aligned_with_the_API_spec":0.097,"P\\Tests\\Api\\ApiFixturesCleanupTest::__pest_evaluable_it_removes_generated_customer_traces_during_fixture_cleanup":8.099,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_logs_in_with_valid_customer_credentials":0,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_rejects_invalid_login_payloads_and_credentials":0,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_returns_the_cached_auth_session_payload_for_a_valid_token":0,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_includes_economic_runtime_config_for_uncached_auth_sessions":0,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_rejects_invalid_auth_session_tokens":0,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_logs_out_and_invalidates_the_token_for_future_session_calls":0,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_logs_out_and_invalidates_cached_subuser_sessions":0,"P\\Tests\\Api\\AuthApiTest::__pest_evaluable_it_rejects_invalid_logout_tokens":0,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_returns_the_raw_202_gather_webhook_contract_over_HTTP":0.788,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_still_prompts_for_department_selection_when_only_one_department_is_eligible":0.732,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_accepts_the_legacy_initial_webhook_body_with_top_level_call_identifiers":0.692,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_returns_native_flow_gather_data_after_a_top_level_department_selection_when_distinct_gate_choices_exist":1.515,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_opens_the_gate_immediately_after_a_top_level_department_selection_when_entrance_and_exit_share_the_same_gate":1.719,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_uses_a_multi_digit_gather_contract_when_10_departments_are_eligible":0.587,"P\\Tests\\Api\\BirdVoiceWebhookApiTest::__pest_evaluable_it_returns_a_raw_400_transport_error_for_malformed_webhook_payloads":0.419,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_lists_only_visible_departments_and_can_return_a_single_department_with_the_slack_webhook":1.048,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_department_listing_when_the_permission_is_missing":0.378,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_creates_departments_through_the_real_endpoint":0.59,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_invalid_department_create_requests":1.554,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_updates_departments_through_the_real_endpoint":0.788,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_invalid_department_update_requests":1.552,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_lists_department_categories_for_a_department":1.006,"P\\Tests\\Api\\DepartmentsApiTest::__pest_evaluable_it_rejects_invalid_department_category_requests":1.191,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_lists_the_draft_customer_config_entry_in_economic_config_responses":0,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_round_trips_the_draft_customer_config_value_through_economic_config_updates":0,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_rejects_order_draft_exports_for_the_configured_draft_customer":0,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_rejects_booked_invoice_exports_for_the_configured_draft_customer":0,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_rejects_collected_invoice_exports_for_the_configured_draft_customer":0,"P\\Tests\\Api\\PingApiTest::__pest_evaluable_it_returns_the_ping_contract":0.877,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_returns_a_setup_required_error_when_department_terminal_readers_are_requested_without_terminal_setup":1.281,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_sends_a_Stripe_invoice_by_email_and_persists_the_hosted_invoice_association":5.434,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_returns_a_conflict_when_a_Stripe_hosted_invoice_is_already_active_for_the_order":4.756,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_allows_sending_a_new_Stripe_hosted_invoice_when_the_existing_association_is_already_terminal":3.943,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_voids_an_unpaid_Stripe_hosted_invoice_and_clears_the_local_order_association":3.746,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_refuses_to_cancel_a_paid_Stripe_hosted_invoice":4.131,"P\\Tests\\Api\\StripeApiTest::__pest_evaluable_it_returns_a_setup_required_error_when_creating_a_payment_intent_for_a_department_without_terminal_setup":0.726,"P\\Tests\\Api\\VehiclesApiTest::__pest_evaluable_it_returns_the_newest_vehicle_last__order__id_that_still_has_order_items":1.326,"P\\Tests\\Api\\VehiclesApiTest::__pest_evaluable_it_returns_a_null_vehicle_last__order__id_when_no_order_with_items_exists":1.159,"P\\Tests\\Unit\\Users\\EconomicCustomerModelParsingTest::__pest_evaluable_it_parses_cached_economic_customer_payloads_that_use_snake__case_customer__number":0,"P\\Tests\\Unit\\Users\\EconomicCustomerModelParsingTest::__pest_evaluable_it_leaves_the_economic_customer_model_empty_when_no_valid_customer_number_is_present":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayFleetUsageStatisticsTest::__pest_evaluable_it_derives_fleet_usage_directly_from_cached_gateway_row_summaries":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_install_script_urls_with_the_compose_edge_gateway_artifacts_and_forwarded_https_scheme":1.031,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayMetadataUpdateContractTest::__pest_evaluable_it_adds_a_gateway_metadata_update_endpoint_with_label_and_primary_assignment_fields":0.009,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayMetadataUpdateContractTest::__pest_evaluable_it_reassigns_department_primary_gateways_through_dedicated_manager_helpers":0.006,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayShellPollingTransportTest::__pest_evaluable_it_removes_shell_access_from_the_edge_gateway_HTTP_contracts":0.007,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_builds_the_installer_around_the_compose_stack_artifacts_and_management_polling_config":0.06,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayViewCacheTest::__pest_evaluable_it_uses_sane_ttl_defaults_and_deterministic_cache_keys":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayViewCacheTest::__pest_evaluable_it_stores_and_retrieves_cached_list_and_detail_payloads":0.001,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayViewCacheTest::__pest_evaluable_it_syncs_gateway_snapshots_into_cached_list_payloads_and_refreshes_fleet_usage":0.001,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayViewCacheTest::__pest_evaluable_it_removes_deleted_gateways_from_cached_list_and_detail_payloads":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayUpdateLifecycleTest::__pest_evaluable_it_exposes_update_payload__credential_rotation__cancel_endpoints__and_operation_endpoints_without_shell_transport_wiring":0.02,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayModuleRouteWiringTest::__pest_evaluable_it_registers_module_scoped_edge_gateway_operator_routes":0.008,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayModuleRouteWiringTest::__pest_evaluable_it_registers_edge_gateway_module_config_endpoints":0.006,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayModuleRouteWiringTest::__pest_evaluable_it_registers_edge_gateway_config_endpoints_from_the_module_route_directory":0.015,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayModuleRouteWiringTest::__pest_evaluable_it_keeps_only_the_module_facade_in_the_global_classes_directory_and_conditionally_loads_module_routes":0.007,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayInstallSessionLifecycleTest::__pest_evaluable_it_caps_install_session_diagnostics_and_events_while_preserving_the_first_start_timestamp":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayInstallSessionLifecycleTest::__pest_evaluable_it_clears_terminal_failure_details_after_a_successful_claim_update":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayInstallSessionLifecycleTest::__pest_evaluable_it_marks_expired_non_terminal_install_sessions_as_terminal_when_read_back":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_websocket_broker_urls_on_the_traefik_broker_path":0.965,"P\\Tests\\Unit\\Selfserve\\DepartmentGateConfigRelayTest::__pest_evaluable_it_validates_relay_gate_configs_and_keeps_relay_specific_fields_in_the_payload":0,"P\\Tests\\Unit\\Selfserve\\DepartmentGateConfigRelayTest::__pest_evaluable_it_rejects_relay_gate_configs_without_a_logical_relay_id":0,"P\\Tests\\Unit\\Bird\\DepartmentGatesRelayOpenTest::__pest_evaluable_it_dispatches_relay_backed_gates_through_the_edge_gateway_relay_manager":0,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayDepartmentWorkspaceContractTest::__pest_evaluable_it_defines_the_department_hardware_workspace_service_payload_surface":0.033,"P\\Tests\\Unit\\Selfserve\\PlateScannerWorkspaceContractTest::__pest_evaluable_it_adds_lane_aware_scanner_management_and_a_dedicated_rotate_key_action":0.01,"P\\Tests\\Unit\\Selfserve\\PlateScannerWorkspaceContractTest::__pest_evaluable_it_uses_the_scanner_default_lane_before_requiring_an_explicit_lane__id_in_machine_button_webhooks":0.004,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_maps_gateway_relay_status_responses_into_the_Shelly_cloud_payload_shape":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_maps_gateway_relay_switch_responses_into_the_Shelly_cloud_payload_shape":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_requires_a_valid_department_id_for_gateway_transport_requests":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_rejects_unsupported_gateway_transport_endpoints":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_surfaces_binding_lookup_failures_while_resolving_relay_state_through_the_gateway_transport":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_surfaces_offline_gateway_failures_while_dispatching_relay_switch_commands":0,"P\\Tests\\Api\\PlateScannersApiTest::__pest_evaluable_it_returns_the_updated_lane_id_after_editing_a_scanner_whose_null_lane_was_already_cached":21.835,"P\\Tests\\Api\\PlateScannersApiTest::__pest_evaluable_it_rejects_plate_scanner_edits_when_the_permission_is_missing":7.837,"P\\Tests\\Unit\\Selfserve\\ShellyRelayInventoryTest::__pest_evaluable_it_normalizes_owned_Shelly_devices_into_relay_select_options":0,"P\\Tests\\Unit\\Selfserve\\ShellyRelayInventoryTest::__pest_evaluable_it_fails_fast_when_Shelly_inventory_does_not_include_owned_devices_status":0,"P\\Tests\\Unit\\Selfserve\\DepartmentLaneRelayOptionsRouteWiringTest::__pest_evaluable_it_registers_a_department_lane_relay_options_endpoint_backed_by_Shelly_inventory":0.032,"P\\Tests\\Unit\\Selfserve\\DepartmentLaneRelayNullificationRouteWiringTest::__pest_evaluable_it_nullifies_department_lane_relay_fields_when_blank_select_values_are_submitted":0.008,"P\\Tests\\Api\\EdgeGatewayAgentApiTest::__pest_evaluable_it_serves_installer_artifacts_and_recovers_gateway_runtime_status_after_fresh_heartbeats":109.487,"P\\Tests\\Api\\EdgeGatewayAgentApiTest::__pest_evaluable_it_polls_operations_and_commands__submits_results__and_records_broker_presence_for_task_pages":229.768,"P\\Tests\\Api\\EdgeGatewayAgentApiTest::__pest_evaluable_it_rejects_missing_and_invalid_edge_agent_tokens":12.815,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_validates_broker_sessions_and_ingests_presence__telemetry__logs__and_shell_lifecycle_data":180.867,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_builds_broker_backlog_and_completes_gateway_operations_through_broker_endpoints":147.749,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_rejects_invalid_edge_broker_shared_secrets":6.832,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_creates_install_tokens__tracks_installer_status__and_exposes_claimed_gateway_detail_to_authorized_operators":134.744,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_manages_edge_gateway_metadata__bindings__operations__sessions__rotation__cutover__and_deletion":272.219,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_rejects_operator_edge_routes_when_module_permission_or_department_access_is_missing":46.681,"P\\Tests\\Integration\\EdgeGateway\\EdgeGatewayBackendIntegrationTest::__pest_evaluable_it_persists_install_session_updates_and_derives_gateway_runtime_status_from_heartbeats":0.015,"P\\Tests\\Integration\\EdgeGateway\\EdgeGatewayBackendIntegrationTest::__pest_evaluable_it_assembles_tasks__logs__statistics__operations__commands__and_shell_lifecycle_state_from_persisted_records":0.019,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_builds_localhost_websocket_broker_urls_on_the_local_traefik_api_prefix":1.312,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerUrlTest::__pest_evaluable_it_keeps_root_host_api_urls_unprefixed_when_the_request_is_not_under_the_local_api_alias":0.603,"P\\Tests\\Unit\\Customers\\CustomerMassImportServiceTest::__pest_evaluable_it_imports_a_matching_e_conomic_customer_into_the_local_system_when_no_local_record_exists":0.001,"P\\Tests\\Unit\\Customers\\CustomerMassImportServiceTest::__pest_evaluable_it_reports_when_the_local_customer_already_has_a_login_account":0,"P\\Tests\\Unit\\Customers\\CustomerMassImportServiceTest::__pest_evaluable_it_creates_a_new_e_conomic_customer_and_returns_a_created_result_for_new_rows":0,"P\\Tests\\Unit\\Customers\\CustomerMassImportServiceTest::__pest_evaluable_it_creates_the_economic_record_for_an_existing_local_account_when_no_matching_upstream_customer_exists":0,"P\\Tests\\Unit\\Customers\\CustomerMassImportServiceTest::__pest_evaluable_it_rejects_CVR_conflicts_when_the_upstream_customer_number_does_not_match_the_submitted_phone_number":0,"P\\Tests\\Unit\\Customers\\CustomerMassImportServiceTest::__pest_evaluable_it_registers_the_customer_import_route_and_wires_it_through_the_mass_import_service":0.009,"P\\Tests\\Unit\\Users\\UsersCustomerNamesCacheTest::__pest_evaluable_it_builds_customer_name_cache_payloads_from_economic_data_or_display_name_fallbacks":0,"P\\Tests\\Unit\\Users\\UsersCustomerNamesCacheTest::__pest_evaluable_it_guards_bulk_customer_name_cache_writes_behind_a_resolved_payload_check":0.003,"P\\Tests\\Api\\EdgeGatewayOperatorApiTest::__pest_evaluable_it_ignores_and_soft_deletes_edge_gateways_whose_department_no_longer_exists":35.946,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_only_trusts_broker_presence_while_the_broker_heartbeat_is_fresh":1.458,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayManagerHeartbeatStatusTest::__pest_evaluable_it_refreshes_broker_presence_from_broker_telemetry_heartbeats":0.006,"P\\Tests\\Unit\\Selfserve\\ShellyTransportResolverTest::__pest_evaluable_it_resolves_the_injected_gateway_transport_when_a_department_is_in_gateway_mode":0,"P\\Tests\\Unit\\Selfserve\\ShellyTransportResolverTest::__pest_evaluable_it_resolves_the_injected_cloud_transport_when_a_department_is_in_cloud_mode":0,"P\\Tests\\Unit\\Selfserve\\ShellyTransportResolverTest::__pest_evaluable_it_lets_relay_tests_override_the_department_transport_without_changing_department_mode":0,"P\\Tests\\Unit\\Selfserve\\ShellyRelayInventoryTest::__pest_evaluable_it_falls_back_to_local_device_and_control_names_when_Shelly_cloud_list_metadata_is_unavailable":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_uses_local_only_gateway_dispatch_for_explicit_local_transport_overrides":0,"P\\Tests\\Unit\\Selfserve\\ShellyTransportResolverTest::__pest_evaluable_it_marks_non_injected_local_overrides_as_local_only_gateway_transport":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_opens_exit_port_by_switching_relay__out__id_on":0.001,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_opens_entrance_port_by_switching_relay__in__id_on":0,"P\\Tests\\Unit\\Selfserve\\GatewayShellyTransportTest::__pest_evaluable_it_forwards_Shelly_toggle__after_timers_to_gateway_relay_switches":0,"P\\Tests\\Unit\\Selfserve\\ShellyTransportResolverTest::__pest_evaluable_it_marks_non_injected_local_and_gateway_overrides_as_local_only_gateway_transport":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_preserves_local_gateway_diagnostic_metadata_on_relay_status_snapshots":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_includes_positive_relay_timers_in_Shelly_switch_payloads":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_applies_Shelly_transport_overrides_across_self_serve_relay_side_effect_routes":0.029,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayLegacyRouteShimTest::__pest_evaluable_it_keeps_guarded_legacy_root_shims_for_moved_edge_gateway_routes":0.025,"P\\Tests\\Integration\\EdgeGateway\\EdgeGatewayBackendIntegrationTest::__pest_evaluable_it_persists_gateway_cutover_relay_bindings_used_by_self_serve_Shelly_dispatch":0.015,"P\\Tests\\Unit\\Selfserve\\SelfserveLanePortControllerTest::__pest_evaluable_it_passes_explicit_timer_values_when_opening_lane_gates":0,"P\\Tests\\Unit\\Selfserve\\ShellyRelayInventoryTest::__pest_evaluable_it_keeps_Shelly_1_Mini_Gen3_type__model__and_generation_aligned_with_Shelly_metadata":0,"P\\Tests\\Api\\EdgeGatewayBrokerApiTest::__pest_evaluable_it_rejects_shell_session_creation_while_broker_presence_is_unavailable":23.503,"P\\Tests\\Unit\\Selfserve\\EdgeGatewayModuleRouteWiringTest::__pest_evaluable_it_registers_broker_settings_as_editable_edge_gateway_module_config":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_self_serve_session_management_endpoints_and_OpenAPI_coverage":0.011,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_keeps_self_serve_force_stop_distinct_from_normal_STOP_relay_and_gate_behavior":0.015,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_keeps_read_only_self_serve_preview_and_summary_refreshes_from_touching_relay_hardware":0.003,"P\\Tests\\Unit\\Selfserve\\SelfserveWashFlowMachineAllowedWiringTest::__pest_evaluable_it_separates_session_synchronization_from_relay_hardware_synchronization":0.016,"P\\Tests\\Unit\\Selfserve\\ShellyRealRequestGuardTest::__pest_evaluable_it_blocks_and_records_test_mode_Shelly_POST_requests_before_cURL_can_run":0.001,"P\\Tests\\Unit\\Selfserve\\ShellyRealRequestGuardTest::__pest_evaluable_it_blocks_and_records_test_mode_Shelly_GET_requests_before_cURL_can_run":0,"P\\Tests\\Unit\\Selfserve\\ZZZShellyGuardSafetyMetaTest::__pest_evaluable_it_did_not_record_any_real_Shelly_request_attempts_during_self_serve_tests":0,"P\\Tests\\Api\\SelfserveFixtureApiTest::__pest_evaluable_it_creates_a_comprehensive_self_serve_API_scenario_with_demo_relays":0,"P\\Tests\\Api\\SelfserveZZZShellyGuardApiTest::__pest_evaluable_it_did_not_record_any_real_Shelly_request_attempts_during_self_serve_API_tests":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_serializes_questions__conditions__tasks__scopes__and_gateways_into_one_graph":0.239,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_derives_studio_vehicle_type_lookup_rows_from_selectable_wash_products":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_applies_saved_layout_without_changing_graph_semantics":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_keeps_layout_loading_compatible_with_native_PDO_named_placeholders":0.013,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_builds_guided_simulator_debug_payload_with_blockers_and_canvas_annotations":0.028,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_resolves_simulator_gateway_service_bindings_from_lane_relay_slots":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneRelayShellyBatchingTest::__pest_evaluable_it_persists_allowed_services_without_relay_writes_for_pre_start_wash_setup":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_the_all_in_one_self_serve_studio_replacement_endpoints":0.009,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStartEntranceTimeoutTest::__pest_evaluable_it_continues_start_when_entrance_relay_dispatch_times_out_ambiguously":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStartEntranceTimeoutTest::__pest_evaluable_it_still_fails_start_for_non_timeout_entrance_relay_errors":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStartEntranceTimeoutTest::__pest_evaluable_it_parses_deferred_relay_side_effects_on_start_command_arguments":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStartEntranceTimeoutTest::__pest_evaluable_it_skips_cleaner_and_machine_relay_side_effects_when_start_asks_to_defer_them":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStartEntranceTimeoutTest::__pest_evaluable_it_keeps_cleaner_and_machine_relay_side_effects_for_normal_start_commands":0,"P\\Tests\\Unit\\Selfserve\\SelfservePropertyGatePermissionBypassTest::__pest_evaluable_it_allows_property_gate_commands_for_customers_with_an_active_wash_in_the_target_department":0,"P\\Tests\\Unit\\Selfserve\\SelfservePropertyGatePermissionBypassTest::__pest_evaluable_it_does_not_bypass_property_gate_permissions_without_a_positive_customer_number":0,"P\\Tests\\Unit\\Selfserve\\SelfservePropertyGatePermissionBypassTest::__pest_evaluable_it_does_not_bypass_property_gate_permissions_when_the_customer_has_no_active_wash_in_the_target_department":0,"P\\Tests\\Unit\\Selfserve\\SelfserveOpenApiSpecTest::__pest_evaluable_it_documents_the_all_in_one_self_serve_studio_replacement_API":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveNonOwnedVehicleWashAccessTest::__pest_evaluable_it_allows_own_permission_customers_to_preview_borrowed_registration_plates_without_ownership_checks":0.005,"P\\Tests\\Unit\\Selfserve\\SelfserveNonOwnedVehicleWashAccessTest::__pest_evaluable_it_allows_customer_scoped_answers_to_be_stored_for_borrowed_registration_plates":0.003,"P\\Tests\\Unit\\Selfserve\\SelfserveNonOwnedVehicleWashAccessTest::__pest_evaluable_it_does_not_apply_saved_answers_from_another_customer_for_the_same_registration_plate":0.027,"P\\Tests\\Unit\\Selfserve\\SelfserveNonOwnedVehicleWashAccessTest::__pest_evaluable_it_scopes_saved_self_serve_answers_by_customer_number_and_registration_plate":0.004,"P\\Tests\\Unit\\Selfserve\\SelfserveNonOwnedVehicleWashAccessTest::__pest_evaluable_it_loads_saved_answers_from_the_authenticated_customer_context_instead_of_the_plate_owner":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_does_not_count_shifts_without_punches_when_checkIn_checkOut_are_null":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_counts_overtime_minutes_when_a_saved_shift_end_extends_past_the_approved_original_end":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_does_not_count_removed_approved_time_when_a_saved_shift_end_is_shortened":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_caps_current_slot_hours_to_elapsed_minutes_and_zeroes_future_slots":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_counts_checked_in_shifts_without_checkOut_up_to_occurredUntil":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteGuardsTest::__pest_evaluable_it_keeps_the_main_period_response_local_only_for_booked_state_and_customer_names":0.024,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteGuardsTest::__pest_evaluable_it_streams_the_main_period_response_instead_of_encoding_the_full_payload_at_once":0.033,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteGuardsTest::__pest_evaluable_it_maps_batched_period_transaction_rows_to_the_legacy_transaction_response_shape":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteGuardsTest::__pest_evaluable_it_uses_batched_period_transactions_and_keyed_customer_maps_in_the_main_period_route":0.033,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodRouteGuardsTest::__pest_evaluable_it_falls_back_to_configured_e_conomic_default_department_for_missing_customer_default_department_in_distributions":0.021,"P\\Tests\\Api\\EconomicDraftCustomerApiTest::__pest_evaluable_it_round_trips_the_default_distribution_department_config_value_through_economic_config_updates":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_computes_MySQL_GTID_interval_counts_and_coverage_percentages":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_reports_empty_source_GTID_sets_as_caught_up":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_computes_Redis_offset_percentages_safely":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_computes_MariaDB_GTID_coverage_by_domain_sequence":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_normalizes_public_replication_kind_aliases":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_supports_MariaDB_prerequisites_without_requiring_Oracle_MySQL_variables":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_reports_MariaDB_specific_blockers_when_GTID_or_binary_logging_prerequisites_are_missing":0,"P\\Tests\\Unit\\Replication\\ReplicationSecretBoxTest::__pest_evaluable_it_encrypts_replication_secrets_without_storing_plaintext":0.002,"P\\Tests\\Unit\\Replication\\ReplicationSecretBoxTest::__pest_evaluable_it_builds_active_database_and_redis_config_from_encrypted_bootstrap_snapshots":0,"P\\Tests\\Unit\\Replication\\SuperuserReplicationRouteWiringTest::__pest_evaluable_it_registers_superuser_replication_endpoints_and_permissions":0.019,"P\\Tests\\Unit\\Replication\\SuperuserReplicationRouteWiringTest::__pest_evaluable_it_documents_replication_management_in_openapi":0.016,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_generates_replication_ready_MariaDB_compose_templates_without_embedding_secrets":0.001,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_generates_Redis_replica_compose_templates_with_primary_connection_placeholders":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_does_not_require_replica_SQL_threads_before_database_provisioning_configures_them":0.006,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_creates_the_generated_replication_user_on_the_primary_during_provisioning":0.007,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_detects_missing_database_tables_before_provisioning_a_preseeded_replica":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_allows_failed_replicas_to_be_removed_without_allowing_primary_or_healthy_replica_removal":0.033,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_seeds_MariaDB_replicas_in_place_instead_of_requiring_container_recreation":0.024,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_keeps_replication_operation_progress_schema_idempotent_for_existing_installs":0.039,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_limits_MariaDB_log_table_seeding_to_the_last_month":0.002,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_skips_log_table_data_during_MariaDB_seeding_and_replication":0.003,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_keeps_operational_log_tables_schema_only_during_MariaDB_seeding_and_replication":0.005,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_provisions_Redis_replicas_after_a_connectivity_only_preflight_and_reports_sync_progress":0.015,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_keeps_Redis_promotion_caught_up__durable__and_metadata_safe":0.007,"P\\Tests\\Unit\\Auth\\CreateTokenTest::__pest_evaluable_it_creates_auth_tokens_and_reports_missing_customer_users_clearly":0.271,"P\\Tests\\Unit\\Auth\\EconomicCreateCustomerResponseTest::__pest_evaluable_it_returns_the_raw_upstream_create_response_and_preserves_the_requested_payload":0,"P\\Tests\\Unit\\Auth\\RegisterCvrLegacyScriptTest::__pest_evaluable_it_keeps_the_legacy_register_cvr_route_harness_passing":0.043,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_returns_a_raw_202_gather_response_for_initial_department_selection":0.205,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_accepts_legacy_initial_webhook_payloads_with_top_level_call_identifiers":0.005,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_returns_a_flow_data_gather_payload_after_department_selection_in_native_flow_mode_when_distinct_gate_choices_exist":0.002,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_opens_the_gate_immediately_after_department_selection_when_entrance_and_exit_resolve_to_the_same_gate_in_native_flow_mode":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_returns_a_flow_data_completion_payload_after_gate_confirmation_in_native_flow_mode":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_returns_a_second_stage_raw_202_gather_response_after_department_selection_when_distinct_gate_choices_exist":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_opens_the_shared_gate_immediately_after_department_selection_in_raw_command_mode":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_opens_the_selected_gate_and_clears_redis_state_on_final_selection":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_reprompts_with_invalid_selection_while_keeping_webhook_state":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_uses_fallback_dtmf_extraction_when_event_gather_keys_are_missing":0.001,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_prompts_for_department_selection_even_when_only_one_eligible_department_exists":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_prompts_for_a_single_available_gate_type_and_opens_only_after_explicit_confirmation":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_supports_multi_digit_department_selections_before_gate_confirmation":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_returns_a_direct_raw_200_completion_when_no_departments_are_eligible":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_continues_with_the_first_gather_prompt_when_backend_call_acceptance_fails":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteCallLifecycleTest::__pest_evaluable_it_returns_a_business_failure_raw_200_response_when_gate_opening_fails":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_builds_deterministic_department_digit_map_without_capping_options":0.009,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_builds_compact_gate_option_maps_and_resolves_selected_gate_type_by_digit":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_builds_department_prompts_with_multi_digit_guidance_and_compact_gate_prompts":0,"P\\Tests\\Unit\\Bird\\BirdVoiceWebhooksRouteIvrTest::__pest_evaluable_it_normalizes_menu_digit_input_from_Bird_dtmf_payload_values":0,"P\\Tests\\Unit\\Bird\\DepartmentGatesPhoneCallOpenTest::__pest_evaluable_it_forwards_phone_number_and_call_duration_threshold_to_the_Bird_gate_helper":0.002,"P\\Tests\\Unit\\Bird\\DepartmentGatesPhoneCallOpenTest::__pest_evaluable_it_wraps_Bird_helper_failures_and_reports_them_to_Slack":0,"P\\Tests\\Unit\\Bookings\\NonPosBookingCompletionRemovalTest::__pest_evaluable_it_unregisters_legacy_booking_completion_forms":0.13,"P\\Tests\\Unit\\Bookings\\NonPosBookingCompletionRemovalTest::__pest_evaluable_it_keeps_legacy_wash_certificate_downloads_but_disables_generation_and_completion":0.166,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsComplaintsOpenApiSpecTest::__pest_evaluable_it_documents_the_daily_report_complaints_CRUD_and_customer_lookup_endpoints_in_openapi":0.011,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsComplaintsRouteContractTest::__pest_evaluable_it_wires_complaint_create__lookup__list__edit__and_delete_routes_with_validation_and_parsing":0.01,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOutsideHoursOpenApiSpecTest::__pest_evaluable_it_documents_outside_hours_summary_and_trend_schemas_in_openapi":0.016,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOutsideHoursRouteContractTest::__pest_evaluable_it_wires_outside_hours_summary_and_trend_endpoints_through_the_dedicated_statistics_service":0.007,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOutsideHoursRouteContractTest::__pest_evaluable_it_initializes_the_outside_hours_statistics_service_before_building_the_transaction_count_summary_payload":0.006,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOverviewOpenApiSpecTest::__pest_evaluable_it_documents_the_daily_report_overview_endpoint_and_reusable_schemas_in_openapi":0.013,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOverviewRouteTest::__pest_evaluable_it_builds_the_overview_payload_from_batched_repository_data_with_deterministic_tile_states":0.013,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOverviewRouteTest::__pest_evaluable_it_marks_overtime_unavailable_when_not_every_selected_department_can_be_mapped_to_workfeed":0,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOverviewRouteTest::__pest_evaluable_it_normalizes_department_id_input_from_csv_strings_and_nested_values":0,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOverviewRouteTest::__pest_evaluable_it_limits_overtime_counting_to_the_selected_reporting_range":0,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOverviewRouteTest::__pest_evaluable_it_does_not_count_negative_approved_overtime_when_a_saved_end_shortens_the_shift":0,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOverviewRouteTest::__pest_evaluable_it_ignores_late_unapproved_administrative_edits_when_calculating_overtime":0,"P\\Tests\\Unit\\DailyReports\\DepartmentDailyReportsOverviewRouteTest::__pest_evaluable_it_wires_the_overview_route_to_batched_repository_methods_and_overview_path":0.013,"P\\Tests\\Unit\\DailyReports\\DepartmentOutsideHoursStatisticsServiceTest::__pest_evaluable_it_counts_only_outside_hours_washes_and_flags_missing_opening_hours_without_counting_them_as_closed":0.002,"P\\Tests\\Unit\\DailyReports\\DepartmentOutsideHoursStatisticsServiceTest::__pest_evaluable_it_deduplicates_linked_washes_with_self_serve_first__then_xlvask__then_orders":0,"P\\Tests\\Unit\\DailyReports\\DepartmentOutsideHoursStatisticsServiceTest::__pest_evaluable_it_builds_daily_trend_points_with_per_day_missing_hours_diagnostics":0.001,"P\\Tests\\Unit\\Database\\DbObjectRedisNamespaceSafetyTest::__pest_evaluable_it_keeps_db_object_Redis_access_namespace_safe":0.008,"P\\Tests\\Unit\\DynamicImages\\DepartmentLaneDynamicImageRouteTest::__pest_evaluable_it_allows_studio_lane_dynamic_image_previews_to_override_the_saved_image_id":0.012,"P\\Tests\\Unit\\DynamicImages\\DepartmentLaneDynamicImageRouteTest::__pest_evaluable_it_accepts_ordered_dynamic_image_button_tokens_including_reset_start_and_zero":0.003,"P\\Tests\\Unit\\DynamicImages\\DepartmentLaneDynamicImageRouteTest::__pest_evaluable_it_renders_machine_one_dynamic_image_steps_from_the_ordered_button_payload":0.014,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueDetailsSummaryBuilderTest::__pest_evaluable_it_uses_queued_job_payload_customer_context_before_result_data_exists":0,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueRouteHardeningTest::__pest_evaluable_it_exposes_collected_invoice_queue_monitor_and_per_user_terminal_clear_routes":0.008,"P\\Tests\\Unit\\Invoicing\\CollectedInvoiceQueueRouteHardeningTest::__pest_evaluable_it_runs_collected_invoice_queue_batches_through_an_explicit_manual_endpoint":0.026,"P\\Tests\\Unit\\Invoicing\\EconomicAuthTokenFallbackTest::__pest_evaluable_it_bounds_e_conomic_curl_calls_below_the_PHP_request_timeout":0.01,"P\\Tests\\Unit\\Invoicing\\EconomicCustomersDiscountFallbackTest::__pest_evaluable_it_falls_back_to_a_later_customer_template_product_when_earlier_probes_fail_on_missing_currency_prices":0,"P\\Tests\\Unit\\Invoicing\\EconomicCustomersDiscountFallbackTest::__pest_evaluable_it_returns_zero_when_every_customer_template_lookup_fails_due_to_missing_currency_prices":0,"P\\Tests\\Unit\\Invoicing\\EconomicCustomersDiscountFallbackTest::__pest_evaluable_it_rethrows_unrelated_discount_lookup_failures":0,"P\\Tests\\Unit\\Invoicing\\EconomicDraftCustomerOpenApiSpecTest::__pest_evaluable_it_documents_transaction_draft_customer_config_and_auth_runtime_fields_in_all_tracked_openapi_copies":0.306,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueCronEntrypointWiringTest::__pest_evaluable_it_wires_root_cron_entrypoint_to_the_full_cron_scheduler_with_queue_worker_tasks":0.009,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueOpenApiSpecTest::__pest_evaluable_it_documents_additive_collected_invoice_queue_pagination_metadata_and_retry_conflict_semantics":0.009,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueWorkerTickRouteTest::__pest_evaluable_it_exposes_an_explicit_collected_invoice_transfer_queue_run_route_instead_of_ticking_read_endpoints":0.013,"P\\Tests\\Unit\\Invoicing\\EconomicTransferQueueWorkerTickRouteTest::__pest_evaluable_it_keeps_order_transfer_queue_status_routes_read_only":0.005,"P\\Tests\\Unit\\Invoicing\\EconomicV2BookedDepartment75DistributionTest::__pest_evaluable_it_assigns_classified_booked_department_75_amounts_to_fallback_when_no_monthly_basis_exists":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_falls_back_to_system_orders_for_fixed_pricing_using_the_configured_fallback_department":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_falls_back_to_system_orders_for_wash_subscriptions_using_the_configured_fallback_department":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_uses_the_configured_fallback_department_when_a_subscription_has_no_customer_department_basis":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_excludes_orphaned_customer_traces_from_fixed_pricing_and_customer_price_distributions":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceFallbackTest::__pest_evaluable_it_excludes_orphaned_customers_from_subscription_fallback_versions":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceOrderOverrideTest::__pest_evaluable_it_inherits_department_eligibility_when_no_order_override_is_set":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceOrderOverrideTest::__pest_evaluable_it_allows_an_order_level_include_override_to_overrule_department_exclusion":0,"P\\Tests\\Unit\\Invoicing\\EconomicV2DistributionServiceOrderOverrideTest::__pest_evaluable_it_allows_an_order_level_exclude_override_to_overrule_department_inclusion":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_builds_deterministic_automatic_flag_fingerprints_and_interactive_price_message_parts":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_builds_interactive_message_parts_for_order_and_wash_certificate_warnings":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_includes_order_item_preview_context_for_required_order_field_warnings":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_allows_tank_cleaning_products_for_only_tank_cleaning_customers":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_does_not_flag_interior_wash_variants_as_historical_primary_product_mismatches":0.001,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_does_not_report_duplicate_primary_vehicle_products_from_duplicated_detector_rows_for_the_same_order_item":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_uses_attached_wash_certificate_documents_instead_of_safety_seal_text_for_certificate_presence":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_loads_wash_certificate_attachment_presence_from_order_attachment_content":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_uses_the_highest_customer_specific_discount_in_expected_price_breakdowns":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_uses_a_preloaded_e_conomic_global_discount_in_expected_price_breakdowns":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_does_not_report_a_price_mismatch_when_a_product_specific_discount_makes_the_expected_price_zero":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_preloads_and_caches_missing_e_conomic_discounts_before_price_mismatch_detection":0.04,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_seeds_order_item_preview_cache_from_period_rows":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_sorts_manual_flags_before_automatic_warnings_and_preserves_legacy_circle_indicators_without_flags":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_scopes_invoice_period_flags_to_the_customer_card_that_can_render_them":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_keeps_order_item_preview_context_compact_for_the_period_response":0,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_formats_stored_manual_flags_with_the_creating_superuser_display_name":0.003,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_validates_supported_manual_flag_fields_by_target_type":0.136,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_wires_invoice_period_flag_routes_with_explicit_list_create_and_update_permissions":0.043,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_uses_the_users_display__name_column_in_detector_queries":0.115,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_aggregates_customer_price_overrides_by_customer_number_for_price_mismatch_detection":0.115,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_guards_optional_customer_vehicle_deleted__at_filtering_behind_a_column_check":0.097,"P\\Tests\\Unit\\Invoicing\\InvoicePeriodFlagServiceTest::__pest_evaluable_it_limits_historical_primary_product_lookup_to_current_period_registrations":0.08,"P\\Tests\\Unit\\Invoicing\\InvoicingOrdersCalculationsHardeningTest::__pest_evaluable_it_provides_a_batched_plain_row_transaction_query_for_invoicing_period_responses":0.11,"P\\Tests\\Unit\\Invoicing\\InvoicingOrdersCalculationsHardeningTest::__pest_evaluable_it_adds_guarded_composite_indexes_for_invoicing_period_lookups":0.155,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodDraftOverlayTest::__pest_evaluable_it_blocks_invoicing_when_all_actionable_transactions_are_backed_by_valid_e_conomic_drafts":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodDraftOverlayTest::__pest_evaluable_it_keeps_invoicing_available_when_valid_drafts_only_cover_part_of_the_actionable_work":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodDraftOverlayTest::__pest_evaluable_it_excludes_errored__booked__deleted__and_missing_external_id_invoice_collections_at_query_time":0.027,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodDraftOverlayTest::__pest_evaluable_it_still_checks_valid_drafts_when_the_collection_table_has_no_deleted_marker_column":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodDraftOverlayTest::__pest_evaluable_it_blocks_fixed_pricing_and_subscription_customer_level_work_when_a_relevant_valid_draft_exists":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodDraftOverlayTest::__pest_evaluable_it_keeps_queue_blocking_ahead_of_the_draft_label_when_all_work_is_covered_by_queue_or_draft_state":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodPaginationTest::__pest_evaluable_it_detects_paginated_period_mode_only_when_pagination_parameters_are_present":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodPaginationTest::__pest_evaluable_it_normalizes_period_pagination_options_and_clamps_invalid_page_and_limit_values":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodPaginationTest::__pest_evaluable_it_slices_only_the_active_period_view_and_keeps_exact_full_result_type_counts":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodPaginationTest::__pest_evaluable_it_returns_the_entire_active_period_view_when_the_limit_is_all":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodPaginationTest::__pest_evaluable_it_searches_customer_fields_and_order_fields_at_the_customer_card_level":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodPaginationTest::__pest_evaluable_it_applies_requires_action_and_booked_visibility_filters_before_counting_and_slicing":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodQueueOverlayTest::__pest_evaluable_it_normalizes_targeted_customer_number_filters_from_comma_separated_or_repeated_values":0,"P\\Tests\\Unit\\Invoicing\\InvoicingPeriodQueueOverlayTest::__pest_evaluable_it_filters_period_customer_number_candidates_to_targeted_customers_only":0,"P\\Tests\\Unit\\MotorApi\\MotorApiCachedResultTest::__pest_evaluable_it_only_writes_cached_MotorAPI_metadata_when_a_response_object_exists":0,"P\\Tests\\Unit\\Orders\\OrderBookingsCompletionDedupTest::__pest_evaluable_it_attaches_and_emails_a_wash_certificate_when_a_booking_is_already_linked_to_a_pos_order_without_one":0,"P\\Tests\\Unit\\Orders\\OrderBookingsCompletionDedupTest::__pest_evaluable_it_does_not_create_or_email_a_duplicate_wash_certificate_when_a_linked_pos_order_already_has_one":0,"P\\Tests\\Unit\\Orders\\OrderBookingsCompletionDedupTest::__pest_evaluable_it_keeps_standalone_booking_completion_behavior_unchanged_for_wash_certificates":0,"P\\Tests\\Unit\\Orders\\OrdersAutoWashCertificateCompletionTest::__pest_evaluable_it_auto_attaches_a_wash_certificate_on_order_completion_when_a_wash_certificate_item_is_present":0.001,"P\\Tests\\Unit\\Orders\\OrdersAutoWashCertificateCompletionTest::__pest_evaluable_it_keeps_order_completion_idempotent_when_a_wash_certificate_is_already_attached":0,"P\\Tests\\Unit\\Orders\\OrdersAutoWashCertificateCompletionTest::__pest_evaluable_it_allows_blank_safety_seal_values_when_auto_attaching_a_wash_certificate_on_completion":0,"P\\Tests\\Unit\\Orders\\OrdersIncludeInInvoiceOverrideTest::__pest_evaluable_it_lets_the_order_level_override_include_an_otherwise_excluded_order":0,"P\\Tests\\Unit\\Orders\\OrdersIncludeInInvoiceOverrideTest::__pest_evaluable_it_lets_the_order_level_override_exclude_an_otherwise_included_order":0,"P\\Tests\\Unit\\Orders\\OrdersIncludeInInvoiceOverrideTest::__pest_evaluable_it_falls_back_to_the_department_invoicing_rule_when_the_override_is_null":0,"P\\Tests\\Unit\\Orders\\OrdersIncludeInInvoiceOverrideTest::__pest_evaluable_it_serializes_both_raw_and_effective_include__in__invoice_values":0,"P\\Tests\\Unit\\Orders\\OrdersInputNormalizerTest::__pest_evaluable_it_normalizes_sql_and_datetime_local_created__at_values":0,"P\\Tests\\Unit\\Orders\\OrdersInputNormalizerTest::__pest_evaluable_it_rejects_invalid_created__at_values":0,"P\\Tests\\Unit\\Orders\\OrdersInputNormalizerTest::__pest_evaluable_it_normalizes_include__in__invoice_tri_state_inputs":0,"P\\Tests\\Unit\\Orders\\OrdersInputNormalizerTest::__pest_evaluable_it_rejects_invalid_include__in__invoice_values":0,"P\\Tests\\Unit\\Orders\\OrdersRouteSettingsUpdateWiringTest::__pest_evaluable_it_wires_order_create_and_update_routes_through_the_settings_normalizers":0.035,"P\\Tests\\Unit\\Orders\\OrdersRouteSettingsUpdateWiringTest::__pest_evaluable_it_keeps_line_item_invoice_filtering_in_the_order_net_amount_calculation":0.004,"P\\Tests\\Unit\\Orders\\OrdersRouteStripePaymentIntentLifecycleWiringTest::__pest_evaluable_it_wires_mobile_stripe_payment_intent_routes_to_normalized_lifecycle_handling":0.008,"P\\Tests\\Unit\\Orders\\StripePaymentIntentsPersistenceWiringTest::__pest_evaluable_it_wires_stripe_payment_intent_persistence_to_prune_duplicates_and_clear_reader_state_safely":0.104,"P\\Tests\\Unit\\Redis\\RedisAtomicReservationTest::__pest_evaluable_it_does_not_send_empty_mget_commands_to_redis":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_generates_MinIO_replica_compose_templates_without_embedding_secrets":0.001,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_computes_MinIO_free_space_and_catch_up_math_safely":0.001,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_wires_MinIO_replication_through_routes_and_bootstrap_snapshots":0.07,"P\\Tests\\Unit\\Selfserve\\SelfserveConditionEvaluatorTest::__pest_evaluable_it_evaluates_nested_v2_ALL_and_ANY_expression_trees_with_trace_output":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConditionEvaluatorTest::__pest_evaluable_it_evaluates_v2_if__else_if__and_else_condition_branches_in_order":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConditionEvaluatorTest::__pest_evaluable_it_evaluates_v2_case_expressions_against_question_values":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConditionEvaluatorTest::__pest_evaluable_it_returns_false_and_traces_v2_condition_expression_cycles":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_fails_validation_when_nested_conditions_form_cycles":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_migrates_legacy_AND_and_OR_rules_into_grouped_v2_condition_expressions":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_repairs_legacy_defaulted_always_task_gates_during_v2_normalization":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_does_not_let_legacy_defaulted_always_task_gates_bypass_validation":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_rejects_unsupported_legacy_task_target_rules_after_migration":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_validates_v2_expressions_for_empty_used_conditions__missing_refs__invalid_operators__and_cycles":0,"P\\Tests\\Unit\\Selfserve\\SelfserveConfigVersioningTest::__pest_evaluable_it_validates_nested_v2_branch_and_case_expressions":0,"P\\Tests\\Unit\\Selfserve\\SelfserveInProgressWashAccessTest::__pest_evaluable_it_keeps_own_in_progress_self_serve_wash_details_visible_to_the_customer":0,"P\\Tests\\Unit\\Selfserve\\SelfserveInProgressWashAccessTest::__pest_evaluable_it_redacts_another_customers_in_progress_wash_details_during_customer_lane_polling":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_adds_vehicle_type_product_on_STOP_when_the_physical_machine_ON_signal_was_recorded__then_turns_off_cleaner_and_machine_relays":0.014,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_skips_vehicle_type_product_add_when_no_physical_machine_ON_signal_was_recorded_and_only_disables_configured_relays":0.009,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_does_not_use_selector_relay_online_status_as_machine_wash_billing_evidence":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_continues_STOP_when_exit_relay_dispatch_times_out_ambiguously":0,"P\\Tests\\Unit\\Selfserve\\SelfserveLaneStopFlowTest::__pest_evaluable_it_still_fails_STOP_for_non_timeout_exit_relay_errors":0.012,"P\\Tests\\Unit\\Selfserve\\SelfserveMachineSignalTest::__pest_evaluable_it_normalizes_Shelly_input_toggle_ON_events_as_machine_start_signals":0.001,"P\\Tests\\Unit\\Selfserve\\SelfserveMachineSignalTest::__pest_evaluable_it_normalizes_Shelly_switch_ON_events_and_nested_status_payloads":0,"P\\Tests\\Unit\\Selfserve\\SelfserveMachineSignalTest::__pest_evaluable_it_recognizes_Shelly_OFF_events_but_does_not_treat_them_as_billable_machine_starts":0,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_local_edge_gateway_machine_ON_signal_monitor_endpoints":0.021,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_wires_lane_level_self_serve_toggles_through_lane_APIs__guest_payloads__and_edge_workspace_readiness":0.011,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_returns_authoritative_allowed_service_state_from_self_serve_session_summaries":0.006,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_filters_machine_button_tasks_out_of_self_serve_snapshots_when_MACHINE_is_not_allowed":0.006,"P\\Tests\\Unit\\Selfserve\\SelfserveRouteWiringTest::__pest_evaluable_it_blocks_new_self_serve_eligibility_and_session_sync_for_disabled_lanes":0.009,"P\\Tests\\Unit\\Selfserve\\SelfserveSchemaBootstrapCompatibilityTest::__pest_evaluable_it_adds_lane_level_self_serve_enablement_for_existing_department_lanes":0.002,"P\\Tests\\Unit\\Selfserve\\SelfserveSchemaBootstrapCompatibilityTest::__pest_evaluable_it_creates_canvas_only_self_serve_studio_layout_storage":0.002,"P\\Tests\\Unit\\Selfserve\\SelfserveSchemaBootstrapCompatibilityTest::__pest_evaluable_it_uses_mysql_safe_identifiers_for_self_serve_studio_virtual_hardware_storage":0.009,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_serializes_configurable_studio_actions_with_event__gate__scope__and_ordering_edges":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_validates_action_configuration_and_keeps_warnings_non_blocking":0.018,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_serializes_v2_condition_expressions_without_standalone_rule_nodes":0.05,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_serializes_branch_and_case_condition_expression_dependencies":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_keeps_runtime_on_published_v2_configs_and_leaves_draft_JSON_as_the_studio_edit_surface":0.008,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_surfaces_task_attachments_in_studio_graph__simulator__and_flow_responses":0.016,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_exposes_dynamic_images_and_referenced_machine_types_as_studio_lookup_choices":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_keeps_lane_management_fields_on_lane_scope_nodes":0.024,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_routes_studio_lane_graph_operations_through_department__lanes":0.005,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_projects_visible_question_answer_paths_into_grouped_task_service_and_signal_outcomes":0.011,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_truncates_path_outcome_projection_when_the_state_cap_is_reached":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_returns_complete_terminal_path_results_for_wide_question_trees_and_reports_progress":0.364,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_generates_and_merges_virtual_hardware_as_studio_only_relay_coverage":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_renders_virtual_gateway_nodes_and_task_service_edges_in_the_studio_graph":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_inserts_configured_action_signals_into_the_simulator_timeline_in_runtime_order":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_simulates_lane_scoped_wash_start_actions_for_property_gates_and_lane_entrance_ports":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_adds_ordered_simulator_signal_timeline_rows_for_virtual_hardware_dry_runs":0,"P\\Tests\\Unit\\Selfserve\\SelfserveWashFlowMachineAllowedWiringTest::__pest_evaluable_it_infers_legacy_defaulted_always_task_gates_from_condition__id_at_runtime":0,"P\\Tests\\Unit\\Selfserve\\SelfserveWashSessionStateTest::__pest_evaluable_it_treats_terminal_self_serve_wash_session_statuses_as_closed":0,"P\\Tests\\Unit\\Selfserve\\SelfserveWashSessionStateTest::__pest_evaluable_it_freezes_elapsed_self_serve_wash_minutes_at_completion_time":0,"P\\Tests\\Unit\\Subusers\\SubusersRouteManagementContractTest::__pest_evaluable_it_exposes_chauffeur_management_endpoints_on_the_subusers_route":0.728,"P\\Tests\\Unit\\Subusers\\SubusersRouteManagementContractTest::__pest_evaluable_it_includes_grant_management_fields_in_the_subusers_payload_builder":0.014,"P\\Tests\\Unit\\Subusers\\SubusersRouteManagementContractTest::__pest_evaluable_it_links_grant_disable_operations_to_SUBUSERS__DELETE_for_own_customer_managers":0.022,"P\\Tests\\Unit\\Subusers\\SubusersRouteManagementContractTest::__pest_evaluable_it_prevents_own_customer_managers_from_editing_driver_owned_account_profiles":0.018,"P\\Tests\\Unit\\Subusers\\SubusersRouteManagementContractTest::__pest_evaluable_it_only_allows_invite_resend_while_setup_is_still_pending":0.028,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusOpenApiSpecTest::__pest_evaluable_it_documents_the_superuser_system_status_snapshot_endpoint_in_openapi":0.008,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusOpenApiSpecTest::__pest_evaluable_it_defines_the_reusable_system_status_schemas_and_enums":0.006,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusRouteWiringTest::__pest_evaluable_it_registers_the_aggregated_superuser_system_status_endpoint_and_permission":0.006,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusRouteWiringTest::__pest_evaluable_it_keeps_the_legacy_database_status_endpoint_wired_through_the_shared_snapshot_service":0.004,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_reduces_overall_status_using_down_and_degraded_precedence":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_classifies_runtime_usage_percentages_consistently":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_reuses_cached_module_probes_only_when_the_ttl_is_still_valid_and_force_is_false":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_classifies_authenticated_http_probe_responses_conservatively with data set \"dataset \"success\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_classifies_authenticated_http_probe_responses_conservatively with data set \"dataset \"unauthorized\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_classifies_authenticated_http_probe_responses_conservatively with data set \"dataset \"forbidden\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_classifies_authenticated_http_probe_responses_conservatively with data set \"dataset \"rate limited\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_classifies_authenticated_http_probe_responses_conservatively with data set \"dataset \"server error\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_classifies_authenticated_http_probe_responses_conservatively with data set \"dataset \"no response\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_classifies_transport_errors_as_down":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_normalizes_and_deduplicates_warning_entries_for_snapshots":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_interprets_recaptcha_probe_payloads_safely with data set \"dataset \"dummy token rejected but credentials valid\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_interprets_recaptcha_probe_payloads_safely with data set \"dataset \"invalid secret is down\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_interprets_recaptcha_probe_payloads_safely with data set \"dataset \"unexpected validation errors degrade\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"recaptcha\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"email\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"motorapi\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"fxratesapi\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"weatherapi\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"workfeed\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"gatewayapi\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"xlvask\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"limble\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"license plate recognizer\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_the_expected_http_probe_requests_for_newly_supported_modules with data set \"dataset \"bird\"\"":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_uses_runtime_economic_credentials_for_the_economic_probe":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_returns_down_without_attempting_economic_http_calls_when_runtime_credentials_are_missing":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_reports_backup_probe_failures_from_local_validation":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_returns_configured_for_shelly_when_no_known_device_id_is_available_for_probing":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_builds_an_authenticated_shelly_status_probe_when_a_known_device_id_exists":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_validates_selfserve_schema_and_minute_product_configuration":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_reports_invalid_selfserve_minute_configuration_before_touching_the_schema":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_reports_missing_selfserve_minute_products":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_surfaces_selfserve_bootstrap_failures":0,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_adds_localization_metadata_for_disabled_and_missing_config_modules":40.262,"P\\Tests\\Unit\\SystemStatus\\SuperuserSystemStatusServiceTest::__pest_evaluable_it_marks_newly_supported_modules_as_probe_backed_and_leaves_only_truly_unsupported_modules_as_configuration_only":38.499,"P\\Tests\\Unit\\SystemStatus\\SystemSessionActivityTrackerTest::__pest_evaluable_it_detects_device_types_from_common_user_agents":0,"P\\Tests\\Unit\\SystemStatus\\SystemSessionActivityTrackerTest::__pest_evaluable_it_treats_recent_sessions_as_active_within_the_configured_activity_window":0,"P\\Tests\\Unit\\SystemStatus\\SystemSessionActivityTrackerTest::__pest_evaluable_it_converts_database_utc_datetimes_into_timezone_aware_iso_strings":0,"P\\Tests\\Unit\\SystemStatus\\SystemSessionActivityTrackerTest::__pest_evaluable_it_treats_timezone_aware_iso_timestamps_as_active_using_absolute_time":0,"P\\Tests\\Unit\\Tooling\\LegacyTestInventoryTest::__pest_evaluable_it_keeps_every_legacy_PHP_test_accounted_for_in_the_manifest":0.489,"P\\Tests\\Unit\\Tooling\\MySqlSchemaCompatibilityTest::__pest_evaluable_it_keeps_schema_bootstrap_SQL_compatible_with_the_MySQL_runner":6.55,"P\\Tests\\Unit\\Users\\UsersCustomerNamesCacheTest::__pest_evaluable_it_allows_callers_to_resolve_customer_names_without_e_conomic_fallback":0.003,"P\\Tests\\Unit\\Users\\UsersCustomerNamesCacheTest::__pest_evaluable_it_returns_an_empty_customer_name_map_without_touching_cache_for_empty_input":0,"P\\Tests\\Unit\\Users\\UsersCustomerNamesCacheTest::__pest_evaluable_it_returns_no_rows_for_empty_array_field_filters":0,"P\\Tests\\Unit\\Users\\UsersRedisNamespaceSafetyTest::__pest_evaluable_it_keeps_users_Redis_access_namespace_safe":0.005,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherCacheRuntimeBehaviorTest::__pest_evaluable_it_builds_department_weather_preload_targets_in_cli_without_a_request_uri":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherPreloadCronWiringTest::__pest_evaluable_it_registers_and_implements_cron_warming_for_workfeed_employee_name_cache":0.007,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_aggregates_multi_department_slot_statuses_using_worst_severity_when_any_department_is_unhealthy":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_aggregates_all_unhealthy_multi_department_slot_statuses_as_unhealthy":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_returns_unknown_when_aggregated_multi_department_slot_targets_are_missing":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_returns_unknown_when_aggregated_multi_department_slot_has_no_evaluable_hours":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherStatusTargetsTest::__pest_evaluable_it_returns_unknown_when_aggregated_multi_department_slot_has_not_started_yet":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherTargetsRouteWiringTest::__pest_evaluable_it_registers_department_weather_hour_details_route_with_weather_read_and_department_access_checks":0.007,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_calculates_weather_hour_contributions_grouped_per_employee_for_a_slot":0.001,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_calculates_weather_hour_contributions_for_canonical_nested_workfeed_employee_schema":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_extracts_weather_employee_identity_from_supported_shift_payload_shapes":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_prefers_canonical_workfeed_employee_schema_fields_over_generic_employee_names":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_does_not_use_workfeed_schema_name_fields_as_employee_ids":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_returns_a_null_employee_name_when_no_workfeed_employee_name_is_available":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_resolves_missing_workfeed_employee_names_from_the_employees_endpoint":0.042,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_filters_employee_hour_rows_that_cannot_be_resolved_to_a_workfeed_schema_name":0,"P\\Tests\\Unit\\Workfeed\\DepartmentWeatherWorkfeedHoursTest::__pest_evaluable_it_resolves_employee_display_name_from_cache_when_shift_payload_lacks_a_name":0,"P\\Tests\\Unit\\Workfeed\\WorkfeedEmployeeNameFormatterTest::__pest_evaluable_it_formats_workfeed_employee_display_names_from_the_documented_firstname_lastname_schema":0,"P\\Tests\\Unit\\Workfeed\\WorkfeedEmployeeNameFormatterTest::__pest_evaluable_it_prefers_workfeed_schema_names_over_generic_display_name_fields":0,"P\\Tests\\Unit\\Workfeed\\WorkfeedEmployeeNameFormatterTest::__pest_evaluable_it_falls_back_to_legacy_display_name_fields_when_schema_names_are_absent":0,"P\\Tests\\Unit\\Workfeed\\WorkfeedEmployeeNameFormatterTest::__pest_evaluable_it_ignores_placeholder_workfeed_display_names":0,"P\\Tests\\Unit\\Workfeed\\WorkfeedEmployeeNameFormatterTest::__pest_evaluable_it_ignores_employee_id_placeholder_display_names_when_the_id_is_known":0,"P\\Tests\\Unit\\XLVask\\XLVaskAutomationServiceTest::__pest_evaluable_it_normalizes_registrations_for_XL_Vask_automation_signatures":0,"P\\Tests\\Unit\\XLVask\\XLVaskAutomationServiceTest::__pest_evaluable_it_builds_stable_XL_Vask_automation_item_signatures":0,"P\\Tests\\Unit\\XLVask\\XLVaskAutomationServiceTest::__pest_evaluable_it_normalizes_persisted_XL_Vask_usage_log_rows_before_helper_hydration":0,"P\\Tests\\Unit\\XLVask\\XLVaskAutomationServiceTest::__pest_evaluable_it_builds_stable_OpenAI_cache_keys_for_identical_automation_input":0,"P\\Tests\\Unit\\XLVask\\XLVaskAutomationServiceTest::__pest_evaluable_it_changes_OpenAI_cache_keys_when_automation_eligibility_input_changes":0,"P\\Tests\\Unit\\XLVask\\XLVaskAutomationServiceTest::__pest_evaluable_it_declares_a_persistent_OpenAI_cache_table_for_XL_Vask_automation":0.005,"P\\Tests\\Unit\\XLVask\\XLVaskAutomationServiceTest::__pest_evaluable_it_declares_cached_amount_summary_columns_for_XL_Vask_usage_logs":0.012,"P\\Tests\\Unit\\XLVask\\XLVaskAutomationServiceTest::__pest_evaluable_it_scores_same_day_orders_with_matching_XL_Vask_products_and_extra_add_ons_as_attach_suggestions":0.003,"P\\Tests\\Unit\\XLVask\\XLVaskAutomationServiceTest::__pest_evaluable_it_does_not_score_an_order_with_only_the_primary_product_as_a_matching_add_on_attachment":0,"P\\Tests\\Unit\\XLVask\\XLVaskUsageLogHelperTest::__pest_evaluable_it_accepts_persisted_ignore_metadata_from_xlvask_usage_log_rows":0.019,"P\\Tests\\Unit\\XLVask\\XLVaskUsageLogHelperTest::__pest_evaluable_it_calculates_XL_Vask_amount_summaries_without_hydrating_order_item_previews":0.006,"P\\Tests\\Unit\\XLVask\\XLVaskUsageRouteContractTest::__pest_evaluable_it_exposes_direct_linked_order_metadata_on_XL_Vask_usage_order_rows":0.003,"P\\Tests\\Unit\\XLVask\\XLVaskUsageRouteContractTest::__pest_evaluable_it_returns_cached_amount_summaries_on_XL_Vask_usage_order_rows_without_widening_the_usage_log_object_payload":0.003,"P\\Tests\\Integration\\DailyReports\\DepartmentDailyReportComplaintsIntegrationTest::__pest_evaluable_it_counts_complaint_rows_by_department_and_created__at_reporting_range":0.284,"P\\Tests\\Integration\\DailyReports\\DepartmentDailyReportComplaintsIntegrationTest::__pest_evaluable_it_updates_and_deletes_complaint_rows":0.005,"P\\Tests\\Integration\\DailyReports\\DepartmentDailyReportComplaintsIntegrationTest::__pest_evaluable_it_parses_created__by__name_from_the_users_table":0.005,"P\\Tests\\Integration\\DailyReports\\DepartmentOutsideHoursStatisticsServiceIntegrationTest::__pest_evaluable_it_integrates_orders__xlvask__and_self_serve_into_one_outside_hours_summary_with_missing_hours_diagnostics":0.016,"P\\Tests\\Integration\\Invoicing\\EconomicTransferQueueIntegrationTest::__pest_evaluable_it_processes_only_collected_invoice_jobs_and_respects_the_manual_batch_limit":0.014,"P\\Tests\\Integration\\SystemStatus\\SuperuserSystemStatusInfrastructureProbeTest::__pest_evaluable_it_can_probe_the_configured_database_in_integration_mode":0,"P\\Tests\\Integration\\SystemStatus\\SuperuserSystemStatusInfrastructureProbeTest::__pest_evaluable_it_can_probe_redis_in_integration_mode_when_configured":0,"P\\Tests\\Integration\\SystemStatus\\SuperuserSystemStatusInfrastructureProbeTest::__pest_evaluable_it_can_probe_minio_in_integration_mode_when_configured":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_redacts_sensitive_release_timeline_payload_fields_recursively":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_verifies_GitHub_sha256_webhook_signatures":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_normalizes_GitHub_repository_identifiers_for_private_repository_access_checks":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_defines_release_manager_schema__routes__permissions__and_system_status_integration_hooks":0.575,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_summarizes_failed_deployments_and_blocks_promotion_until_a_deployment_succeeds":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_uses_the_selected_Coolify_project_and_resolves_server_UUID_from_the_instance_default":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_supports_isolated_stack_mode_and_names_new_Coolify_services_explicitly":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_creates_Coolify_service_payloads_from_raw_compose_without_a_service_type":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_normalizes_Coolify_API_base_URLs_to_the_v1_API_root":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_parses_generated_env_files_for_Coolify_service_env_bulk_updates":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_prefers_public_Coolify_server_hosts_over_Docker_local_addresses":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_blocks_planned_downtime_operations_against_active_replication_primaries":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_allows_failed_Coolify_replica_targets_to_be_removed_after_the_service_disappears":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_retries_Coolify_maintenance_while_linked_replication_provisioning_is_still_incomplete":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_plans_Hetzner_load_balancer_target_and_service_drift_without_mutating_state":0.24,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_does_not_plan_removal_of_the_last_Hetzner_load_balancer_target":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_plans_removal_only_for_disabled_or_deleted_Hetzner_load_balancer_targets":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_defines_Coolify_schema__route_permissions__and_replication_integration_hooks":0.418,"P\\Tests\\Unit\\ErrorReports\\ErrorReportTest::__pest_evaluable_it_redacts_sensitive_error_report_payload_fields_recursively":0.001,"P\\Tests\\Unit\\ErrorReports\\ErrorReportTest::__pest_evaluable_it_validates_supported_screenshot_data_uris":0,"P\\Tests\\Unit\\ErrorReports\\ErrorReportTest::__pest_evaluable_it_defines_error_report_schema__routes__permissions__storage__and_OpenAPI_docs":0.04,"P\\Tests\\Unit\\Http\\ResponseRequestParametersTest::__pest_evaluable_it_reads_JSON_payloads_for_DELETE_request_parameter_arrays":0,"P\\Tests\\Unit\\Http\\ResponseRequestParametersTest::__pest_evaluable_it_keeps_DELETE_query_parameters_when_no_JSON_body_is_present":0,"P\\Tests\\Unit\\Infrastructure\\CorsReleaseHeadersTest::__pest_evaluable_it_allows_release_telemetry_headers_at_PHP_served_CORS_entry_points":0.424,"P\\Tests\\Unit\\Orders\\OrderBookingsCompletionDedupTest::__pest_evaluable_it_uses_the_linked_pos_order_wash_certificate_item_added_during_mobile_completion":0,"P\\Tests\\Unit\\Replication\\ReplicaFailoverManagerTest::__pest_evaluable_it_normalizes_failover_config_defaults_and_per_kind_enablement":0,"P\\Tests\\Unit\\Replication\\ReplicaFailoverManagerTest::__pest_evaluable_it_requires_strict_fresh_100_percent_replica_status_for_candidates":0,"P\\Tests\\Unit\\Replication\\ReplicaFailoverManagerTest::__pest_evaluable_it_selects_the_freshest_eligible_replica_for_failover":0,"P\\Tests\\Unit\\Replication\\ReplicaFailoverManagerTest::__pest_evaluable_it_promotes_enabled_startup_dependencies_from_snapshot_in_dependency_order":0.004,"P\\Tests\\Unit\\Replication\\ReplicaFailoverManagerTest::__pest_evaluable_it_does_not_promote_a_disabled_dependency_during_startup_failover":0.001,"P\\Tests\\Unit\\Replication\\ReplicaFailoverManagerTest::__pest_evaluable_it_wires_the_failover_module_config_endpoint_and_promotion_paths":0.312,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_can_embed_primary_admin_credentials_in_generated_MariaDB_replica_env_files":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_keeps_operational_and_derived_tables_schema_only_during_MariaDB_seeding_and_replication":0.021,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_supports_metadata_only_replication_host_renames":0.005,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_keeps_MinIO_backup_replicas_bounded_to_the_recent_backup_window":0.014,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_prefills_MinIO_replica_compose_primary_values_from_current_config_when_available":0.001,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_allows_the_MinIO_client_binary_to_be_configured_explicitly":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_supports_MinIO_client_runtime_fallback_configuration":0,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_extracts_host_specific_MariaDB_replication_account_denials":0.001,"P\\Tests\\Unit\\Replication\\ReplicationManagerStatusTest::__pest_evaluable_it_identifies_stopped_database_replication_threads_as_a_restartable_status":0,"P\\Tests\\Unit\\Scanner\\ModuleScannerRouteTest::__pest_evaluable_it_returns_no_plate_LPR_results_without_a_failed_HTTP_status":0.046,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_does_not_invent_GHCR_images_for_Coolify_service_payloads":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_keeps_release_branch_services_out_of_the_production_Coolify_environment_except_beta":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_creates_Coolify_GitHub_App_application_payloads_so_pulls_use_the_app_token":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_does_not_treat_an_existing_Coolify_service_as_an_application_just_because_a_GitHub_App_UUID_is_stored":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_can_use_the_Coolify_instance_default_GitHub_App_when_source_targets_do_not_store_it_yet":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_updates_existing_frontend_Coolify_applications_away_from_legacy_Nixpacks_detection":0,"P\\Tests\\Unit\\Auth\\EconomicCreateCustomerResponseTest::__pest_evaluable_it_adds_supported_CVR_company_fields_to_the_e_conomic_customer_payload":0.014,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_keeps_release_channel_runtime_availability_independent_from_channel_URLs":0.007,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_chooses_a_requested_runtime_channel_only_when_it_is_available_to_the_principal":0.199,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_normalizes_release_assignment_subject_suggestions_without_leaking_private_fields":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_keeps_GitHub_commit_timestamps_in_public_release_manager_commit_payloads":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_requires_non_default_release_channel_runtime_URLs_and_preserves_load_balancer_paths":0.105,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_exposes_release_version_git_commit_metadata_for_runtime_channel_cards":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_builds_gateway_API_auto_provision_context_for_connected_Coolify_servers":0.001,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_adds_explicit_Coolify_application_route_labels_for_gateway_API_domains":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_builds_explicit_Coolify_application_route_labels_for_release_API_targets":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_uses_the_self_contained_Coolify_API_Dockerfile_for_API_applications":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_isolates_and_restores_Hetzner_load_balancer_IP_targets_for_gateway_certificate_bootstrap":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_requires_gateway_ping_probes_to_return_the_API_ping_contract":0,"P\\Tests\\Unit\\Tooling\\ComposerEntrypointTest::__pest_evaluable_it_checks_PSR_HTTP_message_interfaces_before_trusting_a_Composer_vendor_tree":0,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_marks_a_channel_ready_when_all_release_services_are_healthy":0.001,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_reports_non_default_channels_missing_bundles__versions__and_URLs_as_blocking_missing_values":0.024,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_surfaces_failed_latest_deployments_as_critical_promotion_blockers":0,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_flags_isolated_stacks_that_are_missing_database_Redis_and_MinIO_services":0,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_maps_degraded_Coolify_targets_and_reconcile_failures_to_release_service_issues":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_plans_Hetzner_load_balancer_service_health_check_drift_updates":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_returns_structured_errors_for_failed_gateway_certificate_bootstrap_and_verification":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_captures_release_request_context_from_headers_and_runtime_query_parameters":0.004,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_normalizes_channel_prefixed_API_ingress_paths_before_route_dispatch":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_creates_frontend_Coolify_GitHub_App_application_payloads_with_the_release_Dockerfile":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_builds_gateway_frontend_auto_provision_context_with_the_release_Dockerfile":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_allows_explicit_runtime_selection_of_any_enabled_release_channel":0.289,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_chooses_requested_runtime_channels_from_available_channels_or_enabled_channel_slugs":0,"P\\Tests\\Unit\\Coolify\\CoolifyManagerTest::__pest_evaluable_it_normalizes_gateway_probe_paths_for_release_gateway_health_checks":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_resolves_release_deployment_endpoints_from_manual_overrides__URLs__health_checks__and_gateway_defaults":0.001,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_does_not_report_frontend_or_API_URLs_missing_when_target_auto_endpoints_are_resolvable":0,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_offers_a_release_bundle_action_when_exactly_one_deployed_bundle_is_eligible":0.008,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_offers_application_target_preparation_for_path_routed_Coolify_failures":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_maps_the_public_master_API_prefix_to_the_stable_release_channel":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_uses_master_as_the_public_route_slug_for_the_stable_release_channel":0,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_does_not_block_channels_on_unhealthy_data_targets_when_data_services_are_production_shared":0,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_ignores_missing_legacy_bundles_but_still_blocks_on_missing_versions_and_URLs":0.001,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_marks_a_branch_based_channel_ready_without_a_release_bundle_when_app_versions_and_URLs_exist":0,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_does_not_surface_legacy_release_bundle_actions_as_readiness_blockers":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_adds_exact_hidden_question__skipped_action__signal__and_button_decision_causes":0,"P\\Tests\\Api\\OrderBookingsCompletionApiTest::__pest_evaluable_it_rejects_standalone_order_booking_completion_outside_POS":5.621,"P\\Tests\\Api\\OrderBookingsCompletionApiTest::__pest_evaluable_it_allows_linked_POS_order_booking_completion_for_mobile_POS_compatibility":4.121,"P\\Tests\\Api\\OrderBookingsCompletionApiTest::__pest_evaluable_it_accepts_numeric_safety_seal_strings_when_completing_a_linked_POS_order_booking":4.366,"P\\Tests\\Api\\OrderBookingsCompletionApiTest::__pest_evaluable_it_disables_the_legacy_complete_wash_without_certificate_route":1.122,"P\\Tests\\Api\\OrderBookingsUpdateApiTest::__pest_evaluable_it_detaches_an_order_booking_and_clears_the_matching_order_link":5.559,"P\\Tests\\Api\\OrderBookingsUpdateApiTest::__pest_evaluable_it_keeps_the_linked_order_when_order__id_is_omitted_from_an_order_booking_update":5.22,"P\\Tests\\Unit\\DynamicImages\\DepartmentLaneDynamicImageRouteTest::__pest_evaluable_it_accepts_ordered_dynamic_image_button_tokens_including_program_picker_reset_start_and_zero":0.007,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_adds_program_picker_before_mapped_machine_buttons_in_simulator_debug_decisions":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_verifies_CI_release_gate_bearer_tokens_from_dedicated_release_credentials":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_only_chooses_requested_runtime_channels_from_channels_available_to_the_principal":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_ignores_explicit_runtime_selection_for_channels_outside_the_principal_channel_set":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_auto_prepares_path_routed_release_targets_for_Coolify_application_creation":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_offers_application_target_preparation_for_missing_Coolify_service_creation_failures":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_builds_API_Coolify_runtime_environment_from_allowed_process_variables":0.001,"P\\Tests\\Unit\\Infrastructure\\CorsPolicyTest::__pest_evaluable_it_normalizes_URL_like_CORS_entries_to_origins":0,"P\\Tests\\Unit\\Infrastructure\\CorsPolicyTest::__pest_evaluable_it_merges_required_release_and_existing_frontend_origins_into_configured_CORS":0,"P\\Tests\\Unit\\Infrastructure\\CorsPolicyTest::__pest_evaluable_it_builds_credential_safe_normal_CORS_response_headers_for_allowed_origins":0,"P\\Tests\\Unit\\Infrastructure\\CorsPolicyTest::__pest_evaluable_it_builds_preflight_CORS_response_headers_for_api_v2_release_URLs":0,"P\\Tests\\Unit\\Infrastructure\\CorsPolicyTest::__pest_evaluable_it_rejects_unknown_CORS_origins":0,"P\\Tests\\Unit\\Infrastructure\\CorsPolicyTest::__pest_evaluable_it_reflects_the_request_origin_for_wildcard_CORS_instead_of_sending_credentialed_wildcard_headers":0,"P\\Tests\\Unit\\Selfserve\\SelfserveCustomerLaneAccessTest::__pest_evaluable_it_allows_customers_with_own_self_serve_permission_to_use_enabled_self_serve_lanes":0,"P\\Tests\\Unit\\Selfserve\\SelfserveCustomerLaneAccessTest::__pest_evaluable_it_blocks_customer_lane_mutations_without_own_self_serve_permission":0,"P\\Tests\\Unit\\Selfserve\\SelfserveCustomerLaneAccessTest::__pest_evaluable_it_blocks_customer_lane_mutations_when_the_lane_is_not_operationally_enabled":0,"P\\Tests\\Unit\\Selfserve\\SelfserveCustomerLaneAccessTest::__pest_evaluable_it_allows_active_wash_operations_when_the_lane_runtime_belongs_to_the_customer":0,"P\\Tests\\Unit\\Selfserve\\SelfserveCustomerLaneAccessTest::__pest_evaluable_it_falls_back_to_active_department_sessions_for_customer_active_wash_operations":0,"P\\Tests\\Unit\\Selfserve\\SelfserveCustomerLaneAccessTest::__pest_evaluable_it_blocks_active_wash_operations_for_other_customers":0,"P\\Tests\\Unit\\Selfserve\\SelfservePropertyGatePermissionBypassTest::__pest_evaluable_it_does_not_bypass_property_gate_permissions_without_customer_self_serve_permission":0,"P\\Tests\\Unit\\Selfserve\\SelfserveCustomerLaneAccessTest::__pest_evaluable_it_keeps_active_wash_operations_available_if_a_lane_is_disabled_after_start":0,"P\\Tests\\Api\\SelfserveLaneWashInProgressApiTest::__pest_evaluable_it_requires_authentication_before_checking_in_progress_wash_permissions":0,"P\\Tests\\Api\\SelfserveLaneWashInProgressApiTest::__pest_evaluable_it_reports_both_elevated_and_customer_self_serve_permissions_when_lane_polling_is_not_allowed":0,"P\\Tests\\Api\\SelfserveLaneWashInProgressApiTest::__pest_evaluable_it_allows_customer_self_serve_permission_to_view_their_own_in_progress_wash_details":0,"P\\Tests\\Api\\SelfserveLaneWashInProgressApiTest::__pest_evaluable_it_redacts_another_customers_in_progress_wash_from_customer_self_serve_lane_polling":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_keeps_beta_API_runtime_environment_on_production_database_target":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_treats_attach_existing_service_sets_without_data_target_ids_as_production_shared_ready":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_rejects_beta_release_bundles_that_resolve_to_isolated_cloned_or_fresh_data_services":0.01,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_detects_explicit_data_target_ids_so_beta_service_sets_can_stay_data_only":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_resolves_backend_commit_sha_from_API_runtime_environment_in_priority_order":0.009,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_injects_selected_API_commit_into_Coolify_runtime_env_unless_explicitly_set":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_allows_beta_production_service_bundles_only_when_data_services_stay_production_shared":0,"P\\Tests\\Unit\\ReleaseManager\\ReleaseManagerTest::__pest_evaluable_it_keeps_release_branch_services_out_of_the_production_Coolify_environment":0,"P\\Tests\\Unit\\Release\\ReleaseManagerStatusOverviewTest::__pest_evaluable_it_marks_beta_ready_from_the_production_frontend_and_API_services":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_uses_program_picker_button_numbers_as_thumb_selectors_in_simulator_debug_decisions":0,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_upserts_path_editor_answers_into_generated_condition_and_task_config_rows":0.021,"P\\Tests\\Unit\\Selfserve\\SelfserveStudioGraphTest::__pest_evaluable_it_marks_projected_path_confirmations_confirmed_or_stale_by_stable_signatures":0,"P\\Tests\\Unit\\Selfserve\\SelfserveSchemaBootstrapCompatibilityTest::__pest_evaluable_it_creates_self_serve_studio_path_confirmation_storage":0.003,"P\\Tests\\Unit\\Subusers\\SubusersRouteManagementContractTest::__pest_evaluable_it_resolves_subuser_customer_names_without_external_lookups_during_list_requests":0.208}} \ No newline at end of file diff --git a/services/nginx/app/build/logs/api-server.err.log b/services/nginx/app/build/logs/api-server.err.log index b3720cf6..094e7aaf 100644 --- a/services/nginx/app/build/logs/api-server.err.log +++ b/services/nginx/app/build/logs/api-server.err.log @@ -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 diff --git a/services/nginx/app/classes/application_write_freeze.php b/services/nginx/app/classes/application_write_freeze.php new file mode 100644 index 00000000..1f65c201 --- /dev/null +++ b/services/nginx/app/classes/application_write_freeze.php @@ -0,0 +1,110 @@ + $reason, + 'owner' => $owner, + 'created_at' => date('c'), + 'expires_at' => date('c', time() + max(30, $ttlSeconds)), + ]; + + self::writeState($payload); + } + + public static function unfreeze(?string $owner = null): void + { + $state = self::state(); + if ($owner !== null && isset($state['owner']) && $state['owner'] !== $owner) { + return; + } + + $path = self::statePath(); + if (is_file($path)) { + @unlink($path); + } + } + + public static function state(): array + { + $path = self::statePath(); + if (!is_file($path)) { + return []; + } + + $state = json_decode((string)file_get_contents($path), true); + if (!is_array($state)) { + @unlink($path); + return []; + } + + $expiresAt = strtotime((string)($state['expires_at'] ?? '')); + if ($expiresAt !== false && $expiresAt < time()) { + @unlink($path); + return []; + } + + return $state; + } + + public static function isFrozen(): bool + { + return self::state() !== []; + } + + public static function shouldBlock(string $method, string $uri, bool $isCronOrCli): bool + { + if (!self::isFrozen()) { + return false; + } + + if ($isCronOrCli) { + return true; + } + + $method = strtoupper($method); + if (in_array($method, ['GET', 'HEAD', 'OPTIONS'], true)) { + return false; + } + + $path = parse_url($uri, PHP_URL_PATH) ?: ''; + return !str_starts_with($path, '/superuser/replication'); + } + + private static function writeState(array $state): void + { + $path = self::statePath(); + $dir = dirname($path); + if (!is_dir($dir) && !mkdir($dir, 0770, true) && !is_dir($dir)) { + throw new RuntimeException('Could not create write-freeze directory.'); + } + + $tempPath = tempnam($dir, 'write-freeze-'); + if ($tempPath === false) { + throw new RuntimeException('Could not create write-freeze temp file.'); + } + + try { + file_put_contents($tempPath, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL, LOCK_EX); + if (!rename($tempPath, $path)) { + throw new RuntimeException('Could not atomically replace write-freeze state.'); + } + } finally { + if (is_file($tempPath)) { + @unlink($tempPath); + } + } + } + + private static function statePath(): string + { + $root = defined('WD') ? WD : dirname(__DIR__); + return $root . DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR . 'application-write-freeze.json'; + } +} diff --git a/services/nginx/app/classes/cloud_shelly_transport.php b/services/nginx/app/classes/cloud_shelly_transport.php index 04a2fd16..5276cb45 100644 --- a/services/nginx/app/classes/cloud_shelly_transport.php +++ b/services/nginx/app/classes/cloud_shelly_transport.php @@ -9,7 +9,11 @@ use interfaces\shelly_transport_i; class cloud_shelly_transport implements shelly_transport_i { - public function __construct(private readonly ?shelly $client = null) + public function __construct( + private readonly ?shelly $client = null, + private readonly bool $logRelaySignals = true, + private readonly ?edge_gateway_manager $manager = null + ) { } @@ -30,6 +34,47 @@ class cloud_shelly_transport implements shelly_transport_i public function sendPostRequest(string $endpoint, array $data, ?int $department_id = null): array|object|null { - return $this->client()->sendPostRequest($endpoint, $data); + try { + $response = $this->client()->sendPostRequest($endpoint, $data); + $this->logRelaySignal($endpoint, $data, $department_id, $response, null); + return $response; + } catch (\Throwable $exception) { + $this->logRelaySignal($endpoint, $data, $department_id, null, $exception); + throw $exception; + } + } + + private function logRelaySignal( + string $endpoint, + array $data, + ?int $department_id, + array|object|null $response, + ?\Throwable $exception + ): void { + if (!$this->logRelaySignals || $department_id === null || $department_id <= 0 || !$this->isRelayEndpoint($endpoint)) { + return; + } + + try { + $this->manager()->appendRelayTransportLog( + $department_id, + $endpoint, + $data, + $response, + 'cloud', + $exception?->getMessage() + ); + } catch (\Throwable) { + } + } + + private function isRelayEndpoint(string $endpoint): bool + { + return in_array($endpoint, ['/v2/devices/api/get', '/v2/devices/api/set/switch'], true); + } + + private function manager(): edge_gateway_manager + { + return $this->manager ?? new edge_gateway_manager(); } } diff --git a/services/nginx/app/classes/coolify.php b/services/nginx/app/classes/coolify.php new file mode 100644 index 00000000..c8840b40 --- /dev/null +++ b/services/nginx/app/classes/coolify.php @@ -0,0 +1,39 @@ +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; + } + } +} diff --git a/services/nginx/app/classes/coolify_api_client.php b/services/nginx/app/classes/coolify_api_client.php new file mode 100644 index 00000000..cfdeb9ee --- /dev/null +++ b/services/nginx/app/classes/coolify_api_client.php @@ -0,0 +1,264 @@ +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; + } +} diff --git a/services/nginx/app/classes/coolify_manager.php b/services/nginx/app/classes/coolify_manager.php new file mode 100644 index 00000000..f174ac1f --- /dev/null +++ b/services/nginx/app/classes/coolify_manager.php @@ -0,0 +1,4779 @@ + 'http', + 'listen_port' => 80, + 'destination_port' => 80, + 'health_check' => ['protocol' => 'tcp', 'port' => 80, 'interval' => 15, 'timeout' => 10, 'retries' => 3], + 'http' => ['redirect_http' => false, 'sticky_sessions' => false, 'cookie_name' => 'HCLBSTICKY', 'cookie_lifetime' => 300], + ], + [ + 'protocol' => 'tcp', + 'listen_port' => 443, + 'destination_port' => 443, + 'health_check' => ['protocol' => 'tcp', 'port' => 443, 'interval' => 15, 'timeout' => 10, 'retries' => 3], + ], + ]; + + /** @var callable|null */ + private $clientFactory; + /** @var callable|null */ + private $hetznerClientFactory; + private bool $schemaEnsured = false; + + public function __construct(?callable $clientFactory = null, ?callable $hetznerClientFactory = null) + { + $this->clientFactory = $clientFactory; + $this->hetznerClientFactory = $hetznerClientFactory; + } + + public function summary(): array + { + $this->ensureSchema(); + + return [ + 'generated_at' => date('c'), + 'instances' => $this->listInstances(), + 'targets' => $this->listTargets(), + 'availability' => $this->availabilitySummary(), + 'load_balancer' => $this->loadBalancerSummary(), + ]; + } + + public function listInstances(): array + { + if (!coolify_schema_bootstrap::tablesExist()) { + return []; + } + + return array_map( + fn(array $instance): array => $this->publicInstance($instance), + $this->selectRows('SELECT * FROM coolify_instances WHERE deleted_at IS NULL ORDER BY id') + ); + } + + public function createInstance(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $label = trim((string)($input['label'] ?? 'Coolify')); + $baseUrl = trim((string)($input['base_url'] ?? $input['url'] ?? '')); + $apiToken = (string)($input['api_token'] ?? $input['token'] ?? ''); + if ($label === '' || $baseUrl === '' || trim($apiToken) === '') { + throw new RuntimeException('Coolify label, base URL, and API token are required.'); + } + + $this->execute( + "INSERT INTO coolify_instances ( + label, base_url, api_token_secret, default_project_uuid, default_environment_uuid, + default_environment_name, default_server_uuid, default_destination_uuid + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + 'ssssssss', + [ + $label, + rtrim($baseUrl, '/'), + replication_secret_box::encrypt($apiToken), + null, + null, + null, + null, + null, + ] + ); + + $id = $this->insertId(); + $this->setModuleEnabled(true); + $this->audit(null, $id, null, 'instance_created', $actorUserId, 'info', [ + 'label' => $label, + 'base_url' => $baseUrl, + ]); + + return $this->publicInstance($this->getInstance($id)); + } + + public function testInstance(int $instanceId, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $instance = $this->getInstance($instanceId); + $startedAt = microtime(true); + + try { + $client = $this->clientForInstance($instance); + $health = $client->healthcheck(); + $version = []; + try { + $version = $client->version(); + } catch (Throwable) { + } + + $result = [ + 'ok' => true, + 'status' => 'ok', + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'health' => $health, + 'version' => $version, + 'checked_at' => date('c'), + ]; + + $this->execute( + "UPDATE coolify_instances SET status = 'ok', last_checked_at = NOW(), last_error = NULL WHERE id = ?", + 'i', + [$instanceId] + ); + $this->audit(null, $instanceId, null, 'instance_tested', $actorUserId, 'info', $result); + + return [ + 'instance' => $this->publicInstance($this->getInstance($instanceId)), + 'test' => $result, + ]; + } catch (Throwable $throwable) { + $result = [ + 'ok' => false, + 'status' => 'down', + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'error' => $throwable->getMessage(), + 'checked_at' => date('c'), + ]; + $this->execute( + "UPDATE coolify_instances SET status = 'down', last_checked_at = NOW(), last_error = ? WHERE id = ?", + 'si', + [$throwable->getMessage(), $instanceId] + ); + $this->audit(null, $instanceId, null, 'instance_test_failed', $actorUserId, 'warning', $result); + + return [ + 'instance' => $this->publicInstance($this->getInstance($instanceId)), + 'test' => $result, + ]; + } + } + + public function discoverInstancePlacement(int $instanceId): array + { + $this->ensureSchema(); + $instance = $this->getInstance($instanceId); + $client = $this->clientForInstance($instance); + $errors = []; + + $servers = []; + try { + $servers = array_map( + fn(array $server): array => $this->publicPlacementServer($server), + $this->coolifyCollection($client->listServers()) + ); + } catch (Throwable $throwable) { + $errors['servers'] = $throwable->getMessage(); + } + + $projects = []; + $environments = []; + try { + $projects = array_map( + fn(array $project): array => $this->publicPlacementProject($project), + $this->coolifyCollection($client->listProjects()) + ); + + foreach ($projects as $project) { + $projectUuid = (string)($project['uuid'] ?? ''); + if ($projectUuid === '') { + continue; + } + + try { + foreach ($this->coolifyCollection($client->listProjectEnvironments($projectUuid)) as $environment) { + $environments[] = $this->publicPlacementEnvironment($environment, $project); + } + } catch (Throwable $throwable) { + $errors['environments'][$projectUuid] = $throwable->getMessage(); + } + } + } catch (Throwable $throwable) { + $errors['projects'] = $throwable->getMessage(); + } + + return [ + 'generated_at' => date('c'), + 'instance' => $this->publicInstance($instance), + 'servers' => array_values(array_filter($servers, static fn(array $server): bool => (string)($server['uuid'] ?? '') !== '')), + 'projects' => array_values(array_filter($projects, static fn(array $project): bool => (string)($project['uuid'] ?? '') !== '')), + 'environments' => array_values(array_filter($environments, static fn(array $environment): bool => (string)($environment['name'] ?? $environment['uuid'] ?? '') !== '')), + 'destination_discovery_supported' => false, + 'errors' => $errors, + ]; + } + + public function listTargets(?string $kind = null): array + { + if (!coolify_schema_bootstrap::tablesExist()) { + return []; + } + + $types = ''; + $params = []; + $where = ['t.deleted_at IS NULL']; + if ($kind !== null && trim($kind) !== '') { + $where[] = 't.kind = ?'; + $types .= 's'; + $params[] = replication_manager::normalizeKind($kind); + } + + $targets = $this->selectRows( + "SELECT t.*, i.label AS instance_label, i.base_url AS instance_base_url, h.label AS replication_label, + h.host AS replication_host, h.port AS replication_port, h.role AS replication_role, + h.status AS replication_status, h.last_status_json AS replication_last_status_json, + h.last_checked_at AS replication_last_checked_at + FROM coolify_targets t + INNER JOIN coolify_instances i ON i.id = t.instance_id + LEFT JOIN replication_hosts h ON h.id = t.replication_host_id + WHERE " . implode(' AND ', $where) . ' + ORDER BY FIELD(t.kind, \'database\', \'redis\', \'minio\'), t.id', + $types, + $params + ); + + return array_map(fn(array $target): array => $this->publicTarget($target), $targets); + } + + public function createTarget(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $kind = replication_manager::normalizeKind((string)($input['kind'] ?? '')); + $role = strtolower(trim((string)($input['role'] ?? 'replica'))); + if ($role !== 'replica') { + throw new RuntimeException('Coolify-managed targets must be deployed as replicas first to avoid planned downtime.'); + } + + $instanceId = (int)($input['instance_id'] ?? 0); + if ($instanceId <= 0) { + $instanceId = $this->defaultInstanceId(); + } + $isolatedStack = $this->isIsolatedStackTargetRequest($input); + $instance = $this->getInstance($instanceId); + $input = $this->applyCoolifyDeploymentDefaults($input, $instance); + $input = $this->applyCoolifyPortDefaults($kind, $input, $instance); + + $composeInput = $this->composeInputFromRequest($kind, $input, $instance); + $template = replication_manager::composeTemplate($composeInput); + $hostPayload = $this->hostPayloadFromTemplate($kind, $input, $template); + $hostPayload['options'] = array_replace( + is_array($hostPayload['options'] ?? null) ? $hostPayload['options'] : [], + [ + 'deployment_provider' => 'coolify', + 'coolify_instance_id' => $instanceId, + ] + ); + + $replicationHost = (new replication_manager())->addHost($kind, $hostPayload, $actorUserId); + $replicationHostId = (int)$replicationHost['id']; + $label = trim((string)($input['label'] ?? $replicationHost['label'] ?? $template['service_name'] ?? 'Coolify target')); + $resourceName = self::resourceName($kind, (string)($template['service_name'] ?? $label), $replicationHostId); + $targetOptions = $this->targetOptions($input, $template, $composeInput); + + $this->execute( + "INSERT INTO coolify_targets ( + instance_id, replication_host_id, kind, label, role, server_uuid, project_uuid, + environment_uuid, environment_name, destination_uuid, resource_name, deployment_status, + availability_state, desired_compose_hash, options_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 'degraded', ?, ?)", + 'iisssssssssss', + [ + $instanceId, + $replicationHostId, + $kind, + $label, + $role, + $this->targetMapping($input, $instance, 'server_uuid'), + $this->targetMapping($input, $instance, 'project_uuid'), + $this->targetMapping($input, $instance, 'environment_uuid'), + $this->targetMapping($input, $instance, 'environment_name') ?: 'production', + $this->targetMapping($input, $instance, 'destination_uuid'), + $resourceName, + $this->composeHash($template), + self::jsonEncode($targetOptions), + ] + ); + + $targetId = $this->insertId(); + $this->attachTargetToReplicationHost($kind, $replicationHostId, $targetId, $instanceId); + if (!$isolatedStack) { + $this->ensureFailoverEnabled($kind); + } + $this->audit($targetId, $instanceId, $replicationHostId, 'target_created', $actorUserId, 'info', [ + 'kind' => $kind, + 'role' => $role, + 'resource_name' => $resourceName, + 'isolated_stack' => $isolatedStack, + ]); + + $target = $this->getTarget($targetId); + $deploy = $this->toBool($input['deploy'] ?? false, false); + if ($deploy) { + try { + $this->deployTarget($targetId, $actorUserId); + } catch (Throwable $throwable) { + $this->markTargetFailure($targetId, 'reconcile_failed', $throwable->getMessage(), [ + 'stage' => 'create_target_deploy', + ]); + } + } + + return [ + 'target' => $this->publicTarget($this->getTarget($targetId)), + 'host' => $replicationHost, + ]; + } + + public function reconcileTarget(int $targetId, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $target = $this->getTarget($targetId); + $host = $this->replicationHost((int)$target['replication_host_id']); + if (self::blocksPrimaryMutation($host, 'deploy')) { + return $this->blockedTargetOperation($target, $host, 'deploy', $actorUserId); + } + + $operationId = $this->startOperation($targetId, (int)$target['instance_id'], 'reconcile', $actorUserId); + + try { + $instance = $this->getInstance((int)$target['instance_id']); + $client = $this->clientForInstance($instance); + $host = $this->syncReplicationHostPortsForTarget($target, $host); + $host = $this->syncReplicationHostEndpointForTarget($target, $host, $instance); + $template = $this->composeTemplateForTarget($target, $host); + $env = self::parseEnvFile((string)($template['env'] ?? '')); + $hash = $this->composeHash($template); + $payload = $this->servicePayload($target, $template, false); + $resourceUuid = trim((string)($target['resource_uuid'] ?? '')); + $action = 'in_sync'; + $apiResult = []; + $shouldStart = in_array((string)($target['deployment_status'] ?? ''), ['pending', 'reconcile_failed', 'created', 'deploying', 'provision_blocked'], true); + + if ($resourceUuid === '') { + $apiResult = $client->createService($payload); + $resourceUuid = (string)($apiResult['uuid'] ?? ''); + if ($resourceUuid === '') { + throw new RuntimeException('Coolify did not return a service UUID.'); + } + $this->recordCreatedResource($targetId, $resourceUuid, $hash); + $action = 'created'; + $shouldStart = true; + } elseif ($hash !== (string)($target['desired_compose_hash'] ?? '')) { + $apiResult = $client->updateService($resourceUuid, $this->servicePayload($target, $template, true)); + $action = 'updated'; + $shouldStart = true; + } else { + try { + $apiResult = $client->getService($resourceUuid); + } catch (Throwable) { + $apiResult = []; + } + $action = $shouldStart ? 'start_requested' : 'in_sync'; + } + + if ($env !== []) { + $client->updateServiceEnvsBulk($resourceUuid, $env); + } + $startResult = null; + if ($shouldStart) { + $startResult = $this->startOrRestartService($client, $resourceUuid, $action === 'updated'); + } + + $context = [ + 'action' => $action, + 'resource_uuid' => $resourceUuid, + 'compose_hash' => $hash, + 'coolify' => self::redactCoolifyResponse($apiResult), + 'start' => self::redactCoolifyResponse(is_array($startResult) ? $startResult : []), + ]; + $availabilityState = $this->availabilityStateForHost($host); + $this->execute( + "UPDATE coolify_targets + SET resource_uuid = ?, deployment_status = ?, availability_state = ?, desired_compose_hash = ?, + last_reconcile_status = ?, last_reconcile_json = ?, last_reconciled_at = NOW() + WHERE id = ?", + 'ssssssi', + [ + $resourceUuid, + $action === 'in_sync' ? 'in_sync' : 'deploying', + $availabilityState, + $hash, + $action, + self::jsonEncode($context), + $targetId, + ] + ); + $this->finishOperation($operationId, 'completed', 'Coolify reconcile completed.', []); + $this->audit($targetId, (int)$target['instance_id'], (int)$target['replication_host_id'], 'target_reconciled', $actorUserId, 'info', $context); + + return [ + 'ok' => true, + 'status' => $action, + 'target' => $this->publicTarget($this->getTarget($targetId)), + 'context' => $context, + ]; + } catch (Throwable $throwable) { + $this->finishOperation($operationId, 'failed', null, [$throwable->getMessage()]); + $this->markTargetFailure($targetId, 'reconcile_failed', $throwable->getMessage()); + $this->audit($targetId, (int)$target['instance_id'], (int)$target['replication_host_id'], 'target_reconcile_failed', $actorUserId, 'error', [ + 'error' => $throwable->getMessage(), + ]); + throw $throwable; + } + } + + public function deployTarget(int $targetId, ?int $actorUserId = null): array + { + $reconcile = $this->reconcileTarget($targetId, $actorUserId); + $target = $this->getTarget($targetId); + $hostId = (int)($target['replication_host_id'] ?? 0); + $provision = null; + + if ($hostId > 0) { + if ($this->targetSkipsReplicationProvisioning($target)) { + $provision = [ + 'ok' => true, + 'skipped' => true, + 'status' => 'isolated_stack_empty_data_service', + 'message' => 'Isolated stack data services are intentionally not attached to production replication.', + ]; + + return [ + 'ok' => true, + 'reconcile' => $reconcile, + 'provision' => $provision, + 'target' => $this->publicTarget($this->getTarget($targetId)), + ]; + } + + $reconcileAction = (string)($reconcile['status'] ?? $reconcile['context']['action'] ?? ''); + if (in_array($reconcileAction, ['created', 'updated'], true)) { + $provision = (string)($target['kind'] ?? '') === 'minio' + ? (new replication_manager())->provisionHost((string)$target['kind'], $hostId, $actorUserId, true) + : $this->deferredProvisionResult(null); + $this->setTargetProvisionState($targetId, $hostId, 'deploying', 'provision_deferred'); + } else { + $provision = $this->attemptTargetProvision($targetId, $target, $hostId, $actorUserId, true); + } + } + + return [ + 'ok' => ($provision['ok'] ?? true) !== false, + 'reconcile' => $reconcile, + 'provision' => $provision, + 'target' => $this->publicTarget($this->getTarget($targetId)), + ]; + } + + private function attemptTargetProvision( + int $targetId, + array $target, + int $hostId, + ?int $actorUserId, + bool $deferLongRunning = false + ): array + { + try { + $provision = (new replication_manager())->provisionHost( + (string)$target['kind'], + $hostId, + $actorUserId, + $deferLongRunning + ); + if (($provision['ok'] ?? false) === false && $this->isTransientProvisionBlock($provision)) { + $this->setTargetProvisionState($targetId, $hostId, 'deploying', 'provision_deferred'); + return $this->deferredProvisionResult($provision); + } + + $completed = (($provision['ok'] ?? false) === true) + && (($provision['operation']['status'] ?? null) !== 'running'); + $this->setTargetProvisionState( + $targetId, + $hostId, + $completed ? 'provisioned' : ((($provision['ok'] ?? false) === true) ? 'deploying' : 'provision_blocked'), + $completed ? 'provisioned' : ((($provision['ok'] ?? false) === true) ? 'provisioning' : 'provision_blocked') + ); + + return $provision; + } catch (Throwable $throwable) { + $this->markTargetFailure($targetId, 'provision_blocked', $throwable->getMessage()); + return [ + 'ok' => false, + 'message' => $throwable->getMessage(), + 'blockers' => [$throwable->getMessage()], + ]; + } + } + + private function setTargetProvisionState(int $targetId, int $hostId, string $deploymentStatus, string $lastReconcileStatus): void + { + $this->execute( + "UPDATE coolify_targets + SET availability_state = ?, deployment_status = ?, last_reconcile_status = ?, last_reconciled_at = NOW() + WHERE id = ?", + 'sssi', + [ + $this->availabilityStateForHost($this->replicationHost($hostId, true)), + $deploymentStatus, + $lastReconcileStatus, + $targetId, + ] + ); + } + + private function deferredProvisionResult(?array $provision): array + { + $blockers = array_values(array_unique(array_filter(array_map( + static fn(mixed $blocker): string => trim((string)$blocker), + is_array($provision['blockers'] ?? null) ? $provision['blockers'] : [] + )))); + + return array_replace($provision ?? [], [ + 'ok' => true, + 'deferred' => true, + 'status' => 'waiting_for_coolify', + 'message' => 'Coolify deployment has started. Replication provisioning will continue after the service port becomes reachable.', + 'blockers' => $blockers, + ]); + } + + private function isTransientProvisionBlock(array $provision): bool + { + $blockers = is_array($provision['blockers'] ?? null) ? $provision['blockers'] : []; + if ($blockers === []) { + return false; + } + + $matched = false; + foreach ($blockers as $blocker) { + $message = strtolower(trim((string)$blocker)); + if ($message === '') { + continue; + } + $isTransient = false; + foreach ([ + 'connection refused', + 'connection timed out', + 'timed out', + 'timeout', + 'failed to connect', + 'could not connect', + 'no route to host', + 'network is unreachable', + 'connection reset', + 'temporarily unavailable', + 'temporary failure', + 'name or service not known', + ] as $needle) { + if (str_contains($message, $needle)) { + $isTransient = true; + $matched = true; + break; + } + } + if (!$isTransient) { + return false; + } + } + + return $matched; + } + + public function restartTarget(int $targetId, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $target = $this->getTarget($targetId); + $host = $this->replicationHost((int)$target['replication_host_id']); + if (self::blocksPrimaryMutation($host, 'restart')) { + return $this->blockedTargetOperation($target, $host, 'restart', $actorUserId); + } + + $resourceUuid = trim((string)($target['resource_uuid'] ?? '')); + if ($resourceUuid === '') { + throw new RuntimeException('Coolify target has no resource UUID yet. Reconcile it first.'); + } + + $operationId = $this->startOperation($targetId, (int)$target['instance_id'], 'restart', $actorUserId); + try { + $result = $this->clientForInstance($this->getInstance((int)$target['instance_id']))->restartService($resourceUuid); + $this->execute( + "UPDATE coolify_targets SET deployment_status = 'restarting', last_reconcile_status = 'restart_requested', last_reconciled_at = NOW() WHERE id = ?", + 'i', + [$targetId] + ); + $this->finishOperation($operationId, 'completed', 'Coolify restart requested.', []); + $this->audit($targetId, (int)$target['instance_id'], (int)$target['replication_host_id'], 'target_restart_requested', $actorUserId, 'warning', [ + 'resource_uuid' => $resourceUuid, + 'coolify' => self::redactCoolifyResponse($result), + ]); + + return [ + 'ok' => true, + 'target' => $this->publicTarget($this->getTarget($targetId)), + 'coolify' => self::redactCoolifyResponse($result), + ]; + } catch (Throwable $throwable) { + $this->finishOperation($operationId, 'failed', null, [$throwable->getMessage()]); + $this->markTargetFailure($targetId, 'restart_failed', $throwable->getMessage()); + throw $throwable; + } + } + + public function failoverTarget(int $targetId, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $target = $this->getTarget($targetId); + $host = $this->replicationHost((int)$target['replication_host_id']); + $kind = (string)$target['kind']; + + if (($host['role'] ?? '') === 'primary') { + $result = (new replication_manager())->runAutomaticFailoverMonitor($actorUserId); + } else { + $result = (new replication_manager())->promoteHost($kind, (int)$host['id'], $actorUserId); + } + + $this->execute( + "UPDATE coolify_targets SET availability_state = ?, last_reconcile_status = 'failover_checked', last_reconciled_at = NOW() WHERE id = ?", + 'si', + [$this->availabilityStateForHost($this->replicationHost((int)$host['id'], true)), $targetId] + ); + $this->audit($targetId, (int)$target['instance_id'], (int)$target['replication_host_id'], 'target_failover_requested', $actorUserId, 'critical', [ + 'result' => $result, + ]); + + return [ + 'ok' => true, + 'target' => $this->publicTarget($this->getTarget($targetId)), + 'failover' => $result, + ]; + } + + public function deleteTarget(int $targetId, array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $target = $this->getTarget($targetId); + $host = $this->replicationHost((int)$target['replication_host_id'], true); + if (($host['role'] ?? '') === 'primary') { + throw new RuntimeException('Coolify cannot delete an active primary target. Promote a healthy replica first.'); + } + + $confirmation = trim((string)($input['confirm'] ?? $input['confirmation'] ?? '')); + $expected = 'delete-coolify-target-' . $targetId; + if ($confirmation !== $expected) { + throw new RuntimeException('Destructive confirmation is required. Send confirm="' . $expected . '".'); + } + + $deleteResource = $this->toBool($input['delete_resource'] ?? false, false); + $resourceUuid = trim((string)($target['resource_uuid'] ?? '')); + $coolifyResult = null; + if ($deleteResource && $resourceUuid !== '') { + $coolifyResult = $this->clientForInstance($this->getInstance((int)$target['instance_id']))->deleteService($resourceUuid); + } + + $hostRemoved = false; + $canRemoveHost = replication_manager::replicationHostCanBeRemoved($host) + || self::targetAllowsReplicaRemoval($target); + if ((int)($host['id'] ?? 0) > 0 && $canRemoveHost) { + (new replication_manager())->removeHost((string)$target['kind'], (int)$host['id'], $actorUserId, false); + $hostRemoved = true; + } + + $this->execute( + "UPDATE coolify_targets SET deleted_at = NOW(), deployment_status = 'removed', availability_state = 'degraded' WHERE id = ?", + 'i', + [$targetId] + ); + $this->audit($targetId, (int)$target['instance_id'], (int)$target['replication_host_id'], 'target_deleted', $actorUserId, 'warning', [ + 'delete_resource' => $deleteResource, + 'host_removed' => $hostRemoved, + 'coolify' => self::redactCoolifyResponse(is_array($coolifyResult) ? $coolifyResult : []), + ]); + + return [ + 'ok' => true, + 'id' => $targetId, + 'host_removed' => $hostRemoved, + 'coolify' => self::redactCoolifyResponse(is_array($coolifyResult) ? $coolifyResult : []), + ]; + } + + public function runAvailabilityMaintenance(?int $actorUserId = null): array + { + $this->ensureSchema(); + $failover = (new replication_manager())->runAutomaticFailoverMonitor($actorUserId); + $updated = []; + foreach ($this->selectRows('SELECT id, kind, replication_host_id, resource_uuid, deployment_status FROM coolify_targets WHERE deleted_at IS NULL') as $target) { + $hostId = (int)($target['replication_host_id'] ?? 0); + if ($hostId <= 0) { + continue; + } + try { + $provision = null; + $host = $this->replicationHost($hostId, true); + if ($this->shouldRetryProvisioning($target, $host) + || $this->hasRunningReplicationProvisionOperation((string)$target['kind'], $hostId)) { + $provision = $this->attemptTargetProvision((int)$target['id'], $target, $hostId, $actorUserId); + } + $state = $this->availabilityStateForHost($this->replicationHost($hostId, true)); + $this->execute('UPDATE coolify_targets SET availability_state = ? WHERE id = ?', 'si', [$state, (int)$target['id']]); + $updated[] = [ + 'id' => (int)$target['id'], + 'availability_state' => $state, + 'deployment_status' => $this->getTargetDeploymentStatus((int)$target['id']), + 'provision' => $provision, + ]; + } catch (Throwable) { + } + } + + return [ + 'ok' => true, + 'failover' => $failover, + 'targets' => $updated, + ]; + } + + public function listLoadBalancerGateways(bool $includeDeleted = false): array + { + $this->ensureSchema(); + $where = $includeDeleted ? '1=1' : 'deleted_at IS NULL'; + return array_map( + fn(array $gateway): array => $this->publicGateway($gateway), + $this->selectRows( + "SELECT * FROM coolify_instance_gateways WHERE $where ORDER BY priority ASC, id ASC" + ) + ); + } + + public function saveLoadBalancerGateway(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + + $id = (int)($input['id'] ?? 0); + $hostname = trim((string)($input['hostname'] ?? '')); + $targetIp = trim((string)($input['target_ip'] ?? $input['ip'] ?? '')); + $enabled = $this->toBool($input['enabled'] ?? true, true) ? 1 : 0; + $priority = max(0, (int)($input['priority'] ?? 100)); + $instanceId = (int)($input['instance_id'] ?? 0); + $instanceIdValue = $instanceId > 0 ? $instanceId : null; + + if ($hostname === '' || $targetIp === '') { + throw new RuntimeException('Gateway hostname and target IP are required.'); + } + + if (filter_var($targetIp, FILTER_VALIDATE_IP) === false) { + throw new RuntimeException('Gateway target IP must be a valid IPv4 or IPv6 address.'); + } + + if ($id > 0) { + $this->execute( + "UPDATE coolify_instance_gateways + SET instance_id = ?, hostname = ?, target_ip = ?, enabled = ?, priority = ?, deleted_at = NULL + WHERE id = ?", + 'issiii', + [$instanceIdValue, $hostname, $targetIp, $enabled, $priority, $id] + ); + $action = 'load_balancer_gateway_updated'; + } else { + $this->execute( + "INSERT INTO coolify_instance_gateways (instance_id, hostname, target_ip, enabled, priority) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + instance_id = VALUES(instance_id), + hostname = VALUES(hostname), + enabled = VALUES(enabled), + priority = VALUES(priority), + deleted_at = NULL", + 'issii', + [$instanceIdValue, $hostname, $targetIp, $enabled, $priority] + ); + $id = $this->insertId(); + if ($id <= 0) { + $row = $this->selectOne('SELECT id FROM coolify_instance_gateways WHERE target_ip = ? LIMIT 1', 's', [$targetIp]); + $id = (int)($row['id'] ?? 0); + } + $action = 'load_balancer_gateway_saved'; + } + + $gateway = $this->getGateway($id); + $this->audit(null, $instanceIdValue, null, $action, $actorUserId, 'info', [ + 'gateway_id' => $id, + 'hostname' => $hostname, + 'target_ip' => $targetIp, + 'enabled' => (bool)$enabled, + 'priority' => $priority, + ]); + + return $this->publicGateway($gateway); + } + + public function testLoadBalancerGateway(int $gatewayId, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $gateway = $this->getGateway($gatewayId); + $publicHost = $this->coolifyConfigValue('public_gateway_host', self::DEFAULT_PUBLIC_GATEWAY_HOST); + $result = $this->probeGatewayTarget((string)$gateway['target_ip'], $publicHost); + $state = ($result['ok'] ?? false) === true ? 'ok' : 'down'; + $this->recordGatewayProbe($gatewayId, $result); + $this->audit(null, isset($gateway['instance_id']) ? (int)$gateway['instance_id'] : null, null, 'load_balancer_gateway_tested', $actorUserId, $state === 'ok' ? 'info' : 'warning', [ + 'gateway_id' => $gatewayId, + 'target_ip' => $gateway['target_ip'] ?? null, + 'result' => $result, + ]); + + return [ + 'gateway' => $this->publicGateway($this->getGateway($gatewayId)), + 'test' => $result, + ]; + } + + public function loadBalancerSummary(): array + { + $this->ensureSchema(); + $config = $this->loadBalancerConfig(); + $gateways = $this->listLoadBalancerGateways(); + $base = [ + 'configured' => $config['load_balancer_id'] !== '' && $config['token_set'], + 'status' => 'not_configured', + 'config' => $this->publicLoadBalancerConfig($config), + 'gateways' => $gateways, + 'load_balancer' => null, + 'drift' => [], + 'last_error' => null, + ]; + + if (!$base['configured']) { + return $base; + } + + try { + $loadBalancer = $this->hetznerClient($config['token'])->getLoadBalancer($config['load_balancer_id']); + $drift = $this->planLoadBalancerReconcile($loadBalancer, $gateways); + $this->syncGatewayLoadBalancerStates($gateways, $drift['actual_target_ips']); + + return array_replace($base, [ + 'status' => $drift['has_drift'] ? 'degraded' : 'ok', + 'gateways' => $this->listLoadBalancerGateways(), + 'load_balancer' => $this->publicLoadBalancer($loadBalancer), + 'drift' => $drift, + ]); + } catch (Throwable $throwable) { + return array_replace($base, [ + 'status' => 'down', + 'last_error' => $throwable->getMessage(), + ]); + } + } + + public function reconcileLoadBalancer(bool $dryRun = true, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $config = $this->loadBalancerConfig(); + if ($config['load_balancer_id'] === '' || !$config['token_set']) { + throw new RuntimeException('Hetzner Load Balancer ID and API token are required.'); + } + + $client = $this->hetznerClient($config['token']); + $loadBalancer = $client->getLoadBalancer($config['load_balancer_id']); + $gateways = $this->listLoadBalancerGateways(true); + $plan = $this->planLoadBalancerReconcile($loadBalancer, $gateways); + $canMutate = !$dryRun && $config['automation_enabled'] && $config['automation_mode'] === 'enforce'; + $applied = []; + $skipped = []; + $errors = []; + + foreach ($plan['actions'] as $action) { + $type = (string)($action['type'] ?? ''); + if ($type === 'skip_remove_target') { + $skipped[] = $action; + continue; + } + + if (!$canMutate) { + $skipped[] = array_replace($action, ['reason' => $action['reason'] ?? 'report_only']); + continue; + } + + try { + if ($type === 'add_target') { + $client->addIpTarget($config['load_balancer_id'], (string)$action['target_ip']); + } elseif ($type === 'remove_target') { + $client->removeIpTarget($config['load_balancer_id'], (string)$action['target_ip']); + } elseif ($type === 'add_service') { + $client->addService( + $config['load_balancer_id'], + (string)$action['protocol'], + (int)$action['listen_port'], + (int)$action['destination_port'], + $action + ); + } elseif ($type === 'update_service') { + $client->updateService( + $config['load_balancer_id'], + (string)$action['protocol'], + (int)$action['listen_port'], + (int)$action['destination_port'], + $action + ); + } else { + $skipped[] = array_replace($action, ['reason' => 'unknown_action']); + continue; + } + $applied[] = $action; + } catch (hetzner_cloud_api_exception $exception) { + if (($action['type'] ?? '') === 'add_target' && $exception->apiCode() === 'target_already_defined') { + $applied[] = array_replace($action, ['already_defined' => true]); + continue; + } + $errors[] = array_replace($action, [ + 'error' => $exception->getMessage(), + 'api_code' => $exception->apiCode(), + ]); + } catch (Throwable $throwable) { + $errors[] = array_replace($action, ['error' => $throwable->getMessage()]); + } + } + + $this->audit(null, null, null, $canMutate ? 'load_balancer_reconcile_applied' : 'load_balancer_reconcile_planned', $actorUserId, $errors === [] ? 'info' : 'warning', [ + 'dry_run' => $dryRun, + 'can_mutate' => $canMutate, + 'automation_enabled' => $config['automation_enabled'], + 'automation_mode' => $config['automation_mode'], + 'load_balancer_id' => $config['load_balancer_id'], + 'actions' => $plan['actions'], + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + ]); + + $freshLoadBalancer = $loadBalancer; + if ($canMutate && $errors === []) { + $freshLoadBalancer = $client->getLoadBalancer($config['load_balancer_id']); + } + $freshPlan = $this->planLoadBalancerReconcile($freshLoadBalancer, $this->listLoadBalancerGateways(true)); + $this->syncGatewayLoadBalancerStates($this->listLoadBalancerGateways(), $freshPlan['actual_target_ips']); + + return [ + 'ok' => $errors === [], + 'dry_run' => $dryRun, + 'mutated' => $canMutate, + 'config' => $this->publicLoadBalancerConfig($config), + 'load_balancer' => $this->publicLoadBalancer($freshLoadBalancer), + 'drift' => $freshPlan, + 'planned' => $plan['actions'], + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + 'gateways' => $this->listLoadBalancerGateways(), + ]; + } + + public function deployGatewayApplicationRoutes(bool $dryRun = true, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $config = $this->loadBalancerConfig(); + $publicHost = trim((string)$config['public_gateway_host']); + if ($publicHost === '') { + throw new RuntimeException('Public gateway host is required before deploying application routes.'); + } + if (!self::isPublicDnsName($publicHost)) { + throw new RuntimeException('Public gateway host must be a DNS name.'); + } + + $publicUrl = 'https://' . $publicHost; + $targets = $this->loadBalancerReleaseGatewayTargets(); + $planned = []; + $applied = []; + $skipped = []; + $errors = []; + $warnings = []; + $coveredTargetIpsByApp = []; + $targetsByApp = []; + $deploymentWaitItems = []; + $gatewayRows = $this->listLoadBalancerGateways(true); + $enabledGatewayIps = array_values(array_unique(array_map( + static fn(array $gateway): string => (string)$gateway['target_ip'], + array_filter( + $gatewayRows, + static fn(array $gateway): bool => !empty($gateway['enabled']) && empty($gateway['deleted_at']) + ) + ))); + + if ($targets === []) { + $warnings[] = 'No Coolify-backed API release target is configured for the gateway host.'; + } + + foreach ($targets as $target) { + $resourceUuid = trim((string)($target['coolify_service_uuid'] ?? '')); + $instanceId = (int)($target['coolify_instance_id'] ?? 0); + $app = self::gatewayRouteApp((string)($target['app'] ?? 'api')); + $resourceType = $this->gatewayRouteResourceType($target); + $targetPublicUrl = self::gatewayRouteTargetPublicUrl($publicHost, $target); + $action = [ + 'type' => 'deploy_gateway_route', + 'target_id' => (int)($target['id'] ?? 0), + 'channel_slug' => $target['channel_slug'] ?? null, + 'app' => $app, + 'resource_uuid' => $resourceUuid, + 'resource_type' => $resourceType, + 'public_url' => $targetPublicUrl, + 'deploy' => true, + ]; + $targetsByApp[$app] ??= []; + $targetsByApp[$app][] = $target; + $coveredTargetIpsByApp[$app] ??= []; + + if ($instanceId <= 0 || $resourceUuid === '') { + $skipped[] = array_replace($action, ['reason' => 'missing_coolify_resource']); + continue; + } + + try { + $instance = $this->getInstance($instanceId); + $client = $this->clientForInstance($instance); + $resource = $resourceType === 'service' + ? $client->getService($resourceUuid) + : $client->getApplication($resourceUuid); + $targetIp = self::resourceServerIp($resource); + if ($targetIp !== null) { + $coveredTargetIpsByApp[$app][] = $targetIp; + $action['target_ip'] = $targetIp; + } + $action['current_public_url'] = self::resourcePublicUrl($resource); + $planned[] = $action; + + if ($dryRun) { + continue; + } + + $updatePayload = $resourceType === 'service' + ? self::gatewayRouteServicePayload( + $targetPublicUrl, + $app, + self::resourceFirstExposedPort($resource, $target) + ) + : self::gatewayRouteApplicationPayload( + $targetPublicUrl, + $resourceUuid, + self::resourceFirstExposedPort($resource, $target), + $resource['custom_labels'] ?? null + ); + $update = $resourceType === 'service' + ? $client->updateService($resourceUuid, $updatePayload) + : $client->updateApplication($resourceUuid, $updatePayload); + $deployment = $client->deployResource($resourceUuid, false); + $deploymentWaitItems = array_merge( + $deploymentWaitItems, + self::coolifyDeploymentWaitItems($deployment, $instanceId, $resourceUuid, [ + 'target_id' => (int)($target['id'] ?? 0), + 'target_ip' => $targetIp, + 'resource_type' => $resourceType, + ]) + ); + $this->persistGatewayRouteTargetContext((int)$target['id'], $target, $publicHost, $targetPublicUrl); + $applied[] = array_replace($action, [ + 'updated' => self::redactCoolifyResponse($update), + 'deployment' => self::redactCoolifyResponse($deployment), + ]); + } catch (Throwable $throwable) { + $errors[] = array_replace($action, ['error' => $throwable->getMessage()]); + } + } + + foreach ($targetsByApp as $app => $appTargets) { + $appCoveredTargetIps = array_values(array_unique(array_filter($coveredTargetIpsByApp[$app] ?? []))); + $appUncoveredGatewayIps = array_values(array_diff($enabledGatewayIps, $appCoveredTargetIps)); + + if ($appUncoveredGatewayIps !== [] && $appTargets !== []) { + $provisioned = $this->provisionMissingGatewayRouteTargets( + $appUncoveredGatewayIps, + $appTargets, + $publicHost, + $publicUrl, + $dryRun, + $actorUserId + ); + $planned = array_merge($planned, $provisioned['planned']); + $applied = array_merge($applied, $provisioned['applied']); + $skipped = array_merge($skipped, $provisioned['skipped']); + $errors = array_merge($errors, $provisioned['errors']); + $warnings = array_merge($warnings, $provisioned['warnings']); + $deploymentWaitItems = array_merge($deploymentWaitItems, $provisioned['deployment_wait_items'] ?? []); + $appCoveredTargetIps = array_values(array_unique(array_merge($appCoveredTargetIps, $provisioned['covered_target_ips']))); + $appUncoveredGatewayIps = array_values(array_diff($enabledGatewayIps, $appCoveredTargetIps)); + } + + $coveredTargetIpsByApp[$app] = $appCoveredTargetIps; + if ($appUncoveredGatewayIps !== [] && $appCoveredTargetIps !== []) { + $warnings[] = 'No managed Coolify ' . $app . ' application route was found for gateway targets: ' . implode(', ', $appUncoveredGatewayIps) . '.'; + } + } + + $coveredTargetIps = array_values(array_unique(array_merge(...array_values($coveredTargetIpsByApp ?: [[]])))); + $uncoveredGatewayIps = array_values(array_diff($enabledGatewayIps, $coveredTargetIps)); + + $certificateBootstrap = null; + $verification = null; + $deploymentWait = null; + if (!$dryRun && $deploymentWaitItems !== []) { + $deploymentWait = $this->waitForCoolifyDeployments($deploymentWaitItems); + if (($deploymentWait['ok'] ?? false) !== true) { + $warnings[] = 'Coolify deployments are still running; Let\'s Encrypt bootstrap was deferred until the next route deploy.'; + } + } + if (!$dryRun && $coveredTargetIps !== [] && ($deploymentWait === null || ($deploymentWait['ok'] ?? false) === true)) { + $certificateBootstrap = $this->bootstrapGatewayCertificates($coveredTargetIps, $publicHost, $config); + $warnings = array_merge($warnings, $certificateBootstrap['warnings'] ?? []); + $verification = $this->verifyGatewayRoutes($gatewayRows, $coveredTargetIps, $publicHost); + if (($verification['ok'] ?? false) !== true) { + $warnings[] = "Gateway route and Let's Encrypt certificate verification is still failing for: " . implode(', ', $verification['failed_target_ips'] ?? []) . '.'; + } + } + $errors = array_merge($errors, self::gatewayRouteHealthErrors($certificateBootstrap, $verification)); + + $this->audit(null, null, null, $dryRun ? 'gateway_application_routes_planned' : 'gateway_application_routes_deployed', $actorUserId, $errors === [] ? 'info' : 'warning', [ + 'dry_run' => $dryRun, + 'public_host' => $publicHost, + 'public_url' => $publicUrl, + 'planned' => $planned, + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + 'warnings' => $warnings, + 'deployment_wait' => $deploymentWait, + 'certificate_bootstrap' => $certificateBootstrap, + 'verification' => $verification, + ]); + + return [ + 'ok' => $errors === [], + 'dry_run' => $dryRun, + 'mutated' => !$dryRun && $errors === [], + 'public_host' => $publicHost, + 'public_url' => $publicUrl, + 'planned' => $planned, + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + 'warnings' => $warnings, + 'deployment_wait' => $deploymentWait, + 'certificate_bootstrap' => $certificateBootstrap, + 'verification' => $verification, + 'coverage' => [ + 'enabled_gateway_ips' => $enabledGatewayIps, + 'covered_target_ips' => $coveredTargetIps, + 'uncovered_gateway_ips' => $uncoveredGatewayIps, + 'apps' => array_map( + static fn(array $ips): array => [ + 'covered_target_ips' => array_values(array_unique(array_filter($ips))), + 'uncovered_gateway_ips' => array_values(array_diff( + $enabledGatewayIps, + array_values(array_unique(array_filter($ips))) + )), + ], + $coveredTargetIpsByApp + ), + ], + 'gateways' => $this->listLoadBalancerGateways(), + ]; + } + + public function deployGatewayApiCode(bool $dryRun = true, bool $deployRoutes = true, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $config = $this->loadBalancerConfig(); + $publicHost = trim((string)$config['public_gateway_host']); + if ($publicHost === '') { + throw new RuntimeException('Public gateway host is required before deploying API code.'); + } + if (!self::isPublicDnsName($publicHost)) { + throw new RuntimeException('Public gateway host must be a DNS name.'); + } + + $publicUrl = 'https://' . $publicHost; + $targets = $this->loadBalancerReleaseApiTargets(); + $planned = []; + $applied = []; + $skipped = []; + $errors = []; + $warnings = []; + $deploymentWaitItems = []; + + if ($targets === []) { + $warnings[] = 'No Coolify-backed API release target is configured for the gateway host.'; + } + + if (!$dryRun) { + if (!class_exists(release_manager::class) && function_exists('app_require')) { + app_require('classes/release_manager.php'); + } + if (!class_exists(release_manager::class)) { + throw new RuntimeException('Release Manager is required to deploy gateway API code.'); + } + } + + $releaseManager = !$dryRun ? new release_manager() : null; + foreach ($targets as $target) { + $repository = trim((string)($target['repository'] ?? '')); + $branch = trim((string)($target['branch'] ?? 'master')) ?: 'master'; + $resourceUuid = trim((string)($target['coolify_service_uuid'] ?? '')); + $instanceId = (int)($target['coolify_instance_id'] ?? 0); + $targetPublicUrl = self::gatewayRouteTargetPublicUrl($publicHost, $target); + $action = [ + 'type' => 'deploy_gateway_api_code', + 'target_id' => (int)($target['id'] ?? 0), + 'channel_id' => (int)($target['channel_id'] ?? 0), + 'channel_slug' => $target['channel_slug'] ?? null, + 'app' => 'api', + 'repository' => $repository, + 'branch' => $branch, + 'resource_uuid' => $resourceUuid, + 'resource_type' => $this->gatewayRouteResourceType($target), + 'public_url' => $targetPublicUrl, + 'commit_mode' => 'latest', + ]; + + if ((int)($target['id'] ?? 0) <= 0 || (int)($target['channel_id'] ?? 0) <= 0) { + $skipped[] = array_replace($action, ['reason' => 'missing_release_target']); + continue; + } + if ($instanceId <= 0 || $resourceUuid === '') { + $skipped[] = array_replace($action, ['reason' => 'missing_coolify_resource']); + continue; + } + if ($repository === '') { + $skipped[] = array_replace($action, ['reason' => 'missing_repository']); + continue; + } + + $planned[] = $action; + if ($dryRun) { + continue; + } + + try { + $deployment = $releaseManager->startDeployment([ + 'target_id' => (int)$target['id'], + 'channel_id' => (int)$target['channel_id'], + 'app' => 'api', + 'repository' => $repository, + 'branch' => $branch, + 'commit_mode' => 'latest', + 'version_label' => $this->gatewayApiCodeVersionLabel($target), + 'deployed_url' => $targetPublicUrl, + 'metadata' => [ + 'gateway_api_code_deploy' => true, + 'public_host' => $publicHost, + 'previous_deployment_id' => isset($target['latest_deployment_id']) ? (int)$target['latest_deployment_id'] : null, + 'previous_commit_sha' => $target['latest_deployment_commit_sha'] ?? null, + ], + ], $actorUserId); + + if ((string)($deployment['status'] ?? '') !== 'deployed') { + $errors[] = array_replace($action, [ + 'deployment_id' => (int)($deployment['id'] ?? 0), + 'status' => $deployment['status'] ?? null, + 'error' => (string)($deployment['error_message'] ?? 'API code deployment did not complete.'), + 'deployment' => $deployment, + ]); + continue; + } + + $deploymentResult = is_array($deployment['result'] ?? null) ? $deployment['result'] : []; + $deploymentWaitItems = array_merge( + $deploymentWaitItems, + self::coolifyDeploymentWaitItems($deploymentResult['deployment'] ?? [], $instanceId, (string)($deploymentResult['service_uuid'] ?? $resourceUuid), [ + 'target_id' => (int)$target['id'], + 'target_ip' => self::targetIpFromDeploymentResult($deploymentResult), + 'resource_type' => (string)($deploymentResult['resource_type'] ?? $action['resource_type']), + ]) + ); + + $applied[] = array_replace($action, [ + 'deployment_id' => (int)($deployment['id'] ?? 0), + 'version_id' => $deployment['version_id'] ?? null, + 'commit_sha' => $deployment['commit_sha'] ?? null, + 'deployment' => $deployment, + ]); + } catch (Throwable $throwable) { + $errors[] = array_replace($action, ['error' => $throwable->getMessage()]); + } + } + + $deploymentWait = null; + if (!$dryRun && $deploymentWaitItems !== []) { + $deploymentWait = $this->waitForCoolifyDeployments($deploymentWaitItems); + if (($deploymentWait['ok'] ?? false) !== true) { + $warnings[] = 'Coolify API code deployments are still running; route and certificate deploy was deferred until the next run.'; + } + } + + $routeDeploy = null; + if (!$dryRun + && $deployRoutes + && $errors === [] + && ($deploymentWait === null || ($deploymentWait['ok'] ?? false) === true)) { + $routeDeploy = $this->deployGatewayApplicationRoutes(false, $actorUserId); + $warnings = array_merge($warnings, $routeDeploy['warnings'] ?? []); + if (($routeDeploy['ok'] ?? false) !== true) { + $errors[] = [ + 'type' => 'deploy_gateway_route_after_code', + 'error' => 'Gateway API code deployed, but route and certificate deployment did not complete.', + 'route_deploy' => $routeDeploy, + ]; + } + } + + $ok = $errors === []; + $this->audit(null, null, null, $dryRun ? 'gateway_api_code_deploy_planned' : 'gateway_api_code_deployed', $actorUserId, $ok ? 'info' : 'warning', [ + 'dry_run' => $dryRun, + 'deploy_routes' => $deployRoutes, + 'public_host' => $publicHost, + 'public_url' => $publicUrl, + 'planned' => $planned, + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + 'warnings' => $warnings, + 'deployment_wait' => $deploymentWait, + 'route_deploy' => $routeDeploy, + ]); + + return [ + 'ok' => $ok, + 'dry_run' => $dryRun, + 'mutated' => !$dryRun && $ok, + 'deploy_routes' => $deployRoutes, + 'public_host' => $publicHost, + 'public_url' => $publicUrl, + 'planned' => $planned, + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + 'warnings' => $warnings, + 'deployment_wait' => $deploymentWait, + 'route_deploy' => $routeDeploy, + 'gateways' => $this->listLoadBalancerGateways(), + ]; + } + + private function gatewayApiCodeVersionLabel(array $target): string + { + $channelSlug = trim((string)($target['channel_slug'] ?? 'gateway')); + $channelSlug = strtolower($channelSlug); + $channelSlug = preg_replace('/[^a-z0-9]+/', '-', $channelSlug) ?: ''; + $channelSlug = trim($channelSlug, '-') ?: 'gateway'; + return $channelSlug . '-api-' . date('Y-m-d-His'); + } + + private static function targetIpFromDeploymentResult(array $deploymentResult): ?string + { + $candidates = [ + $deploymentResult['target_ip'] ?? null, + $deploymentResult['server_ip'] ?? null, + ]; + foreach (['server', 'created', 'updated'] as $key) { + $row = is_array($deploymentResult[$key] ?? null) ? $deploymentResult[$key] : []; + $server = is_array($row['server'] ?? null) ? $row['server'] : ($key === 'server' ? $row : []); + $candidates[] = $server['ip'] ?? null; + $candidates[] = $server['public_ip'] ?? null; + } + + foreach ($candidates as $value) { + $value = trim((string)$value); + if ($value !== '' && filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return $value; + } + } + + return null; + } + + private static function coolifyDeploymentWaitItems(array $deploymentResponse, int $instanceId, string $resourceUuid, array $context = []): array + { + if ($instanceId <= 0) { + return []; + } + + $items = []; + $deployments = is_array($deploymentResponse['deployments'] ?? null) + ? $deploymentResponse['deployments'] + : [$deploymentResponse]; + foreach ($deployments as $deployment) { + if (!is_array($deployment)) { + continue; + } + $deploymentUuid = trim((string)($deployment['deployment_uuid'] ?? $deployment['uuid'] ?? '')); + if ($deploymentUuid === '') { + continue; + } + $items[] = array_replace($context, [ + 'instance_id' => $instanceId, + 'resource_uuid' => trim((string)($deployment['resource_uuid'] ?? $resourceUuid)), + 'deployment_uuid' => $deploymentUuid, + ]); + } + + return $items; + } + + private function waitForCoolifyDeployments(array $items): array + { + $pending = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $deploymentUuid = trim((string)($item['deployment_uuid'] ?? '')); + $instanceId = (int)($item['instance_id'] ?? 0); + if ($deploymentUuid === '' || $instanceId <= 0) { + continue; + } + $pending[$deploymentUuid] = array_replace($item, [ + 'deployment_uuid' => $deploymentUuid, + 'instance_id' => $instanceId, + 'status' => 'queued', + 'last_seen' => null, + ]); + } + + $results = []; + $startedAt = microtime(true); + if ($pending === []) { + return [ + 'ok' => true, + 'skipped' => true, + 'results' => [], + 'pending' => [], + ]; + } + + for ($attempt = 1; $attempt <= self::GATEWAY_ROUTE_DEPLOYMENT_WAIT_ATTEMPTS && $pending !== []; $attempt++) { + $runningByInstance = []; + foreach (array_unique(array_map(static fn(array $item): int => (int)$item['instance_id'], $pending)) as $instanceId) { + try { + $runningByInstance[$instanceId] = $this->coolifyCollection( + $this->clientForInstance($this->getInstance($instanceId))->listDeployments() + ); + } catch (Throwable $throwable) { + foreach ($pending as $uuid => $item) { + if ((int)$item['instance_id'] !== $instanceId) { + continue; + } + $pending[$uuid]['status'] = 'unknown'; + $pending[$uuid]['error'] = $throwable->getMessage(); + } + $runningByInstance[$instanceId] = []; + } + } + + foreach ($pending as $uuid => $item) { + $running = self::findCoolifyDeployment($runningByInstance[(int)$item['instance_id']] ?? [], $uuid); + if ($running === null) { + $results[$uuid] = array_replace($item, [ + 'status' => 'finished_or_not_running', + 'attempt' => $attempt, + ]); + unset($pending[$uuid]); + continue; + } + + $status = strtolower(trim((string)($running['status'] ?? 'running'))); + $pending[$uuid]['status'] = $status; + $pending[$uuid]['last_seen'] = self::redactCoolifyResponse($running); + $pending[$uuid]['attempt'] = $attempt; + + if (in_array($status, ['finished', 'success', 'succeeded', 'failed', 'cancelled', 'canceled'], true)) { + $results[$uuid] = $pending[$uuid]; + unset($pending[$uuid]); + } + } + + if ($pending !== [] && $attempt < self::GATEWAY_ROUTE_DEPLOYMENT_WAIT_ATTEMPTS) { + sleep(self::GATEWAY_ROUTE_VERIFY_DELAY_SECONDS); + } + } + + return [ + 'ok' => $pending === [], + 'skipped' => false, + 'attempts' => self::GATEWAY_ROUTE_DEPLOYMENT_WAIT_ATTEMPTS, + 'delay_seconds' => self::GATEWAY_ROUTE_VERIFY_DELAY_SECONDS, + 'elapsed_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'results' => array_values($results), + 'pending' => array_values($pending), + ]; + } + + private static function findCoolifyDeployment(array $deployments, string $deploymentUuid): ?array + { + foreach ($deployments as $deployment) { + if (!is_array($deployment)) { + continue; + } + if (trim((string)($deployment['deployment_uuid'] ?? $deployment['uuid'] ?? '')) === $deploymentUuid) { + return $deployment; + } + } + + return null; + } + + private function verifyGatewayRoutes(array $gatewayRows, array $targetIps, string $publicHost): array + { + $targetIps = array_values(array_unique(array_filter(array_map('strval', $targetIps)))); + $pending = []; + $results = []; + $startedAt = microtime(true); + + foreach ($gatewayRows as $gateway) { + if (empty($gateway['enabled']) || !empty($gateway['deleted_at'])) { + continue; + } + $targetIp = (string)($gateway['target_ip'] ?? ''); + if ($targetIp === '' || !in_array($targetIp, $targetIps, true)) { + continue; + } + $pending[$targetIp] = $gateway; + } + + for ($attempt = 1; $attempt <= self::GATEWAY_ROUTE_VERIFY_ATTEMPTS && $pending !== []; $attempt++) { + foreach ($pending as $targetIp => $gateway) { + $probe = $this->probeGatewayTarget($targetIp, $publicHost); + $probe['attempt'] = $attempt; + $probe['max_attempts'] = self::GATEWAY_ROUTE_VERIFY_ATTEMPTS; + $this->recordGatewayProbe((int)($gateway['id'] ?? 0), $probe); + $results[$targetIp] = [ + 'gateway_id' => (int)($gateway['id'] ?? 0), + 'hostname' => $gateway['hostname'] ?? null, + 'target_ip' => $targetIp, + 'ok' => (bool)($probe['ok'] ?? false), + 'probe' => $probe, + ]; + + if (($probe['ok'] ?? false) === true) { + unset($pending[$targetIp]); + } + } + + if ($pending !== [] && $attempt < self::GATEWAY_ROUTE_VERIFY_ATTEMPTS) { + sleep(self::GATEWAY_ROUTE_VERIFY_DELAY_SECONDS); + } + } + + return [ + 'ok' => $pending === [], + 'attempts' => self::GATEWAY_ROUTE_VERIFY_ATTEMPTS, + 'delay_seconds' => self::GATEWAY_ROUTE_VERIFY_DELAY_SECONDS, + 'elapsed_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'results' => array_values($results), + 'failed_target_ips' => array_values(array_keys($pending)), + ]; + } + + private static function gatewayRouteHealthErrors(?array $certificateBootstrap, ?array $verification): array + { + $errors = []; + + if (is_array($certificateBootstrap) && ($certificateBootstrap['ok'] ?? true) !== true) { + $errors[] = [ + 'type' => 'certificate_bootstrap_failed', + 'error' => "Let's Encrypt certificate bootstrap failed for one or more gateway targets.", + 'failed_target_ips' => self::failedCertificateBootstrapTargetIps($certificateBootstrap), + 'reason' => $certificateBootstrap['reason'] ?? null, + 'certificate_bootstrap' => $certificateBootstrap, + ]; + } + + if (is_array($verification) && ($verification['ok'] ?? true) !== true) { + $failedTargetIps = array_values(array_unique(array_filter(array_map( + static fn(mixed $targetIp): string => trim((string)$targetIp), + $verification['failed_target_ips'] ?? [] + )))); + $errors[] = [ + 'type' => 'gateway_route_verification_failed', + 'error' => "Gateway route and Let's Encrypt certificate verification is still failing.", + 'failed_target_ips' => $failedTargetIps, + 'verification' => $verification, + ]; + } + + return $errors; + } + + private static function failedCertificateBootstrapTargetIps(array $certificateBootstrap): array + { + $failedTargetIps = []; + foreach (($certificateBootstrap['results'] ?? []) as $result) { + if (!is_array($result) || ($result['ok'] ?? false) === true) { + continue; + } + $targetIp = trim((string)($result['target_ip'] ?? '')); + if ($targetIp !== '') { + $failedTargetIps[] = $targetIp; + } + } + + return array_values(array_unique($failedTargetIps)); + } + + private function bootstrapGatewayCertificates(array $targetIps, string $publicHost, array $config): array + { + $targetIps = array_values(array_unique(array_filter(array_map('strval', $targetIps)))); + $result = [ + 'ok' => true, + 'skipped' => false, + 'results' => [], + 'warnings' => [], + 'restored' => false, + ]; + + if (count($targetIps) < 2) { + $result['skipped'] = true; + $result['reason'] = 'single_target'; + return $result; + } + + if (empty($config['automation_enabled']) + || ($config['automation_mode'] ?? '') !== 'enforce' + || trim((string)($config['load_balancer_id'] ?? '')) === '' + || trim((string)($config['token'] ?? '')) === '') { + $result['skipped'] = true; + $result['ok'] = false; + $result['reason'] = 'load_balancer_enforce_required'; + $result['warnings'][] = "Let's Encrypt certificate bootstrap requires Hetzner load balancer automation in enforce mode."; + return $result; + } + + $client = $this->hetznerClient((string)$config['token']); + $loadBalancerId = (string)$config['load_balancer_id']; + $originalTargetIps = self::loadBalancerIpTargets($client->getLoadBalancer($loadBalancerId)); + $restoreTargetIps = $originalTargetIps !== [] ? $originalTargetIps : $targetIps; + + try { + foreach ($targetIps as $targetIp) { + $targetResult = [ + 'target_ip' => $targetIp, + 'ok' => false, + 'isolated' => false, + 'attempts' => self::GATEWAY_CERT_BOOTSTRAP_ATTEMPTS, + 'last_probe' => null, + ]; + + try { + $this->setLoadBalancerIpTargets($client, $loadBalancerId, [$targetIp]); + $targetResult['isolated'] = true; + + for ($attempt = 1; $attempt <= self::GATEWAY_CERT_BOOTSTRAP_ATTEMPTS; $attempt++) { + $probe = $this->probeGatewayPublicHost($publicHost); + $probe['attempt'] = $attempt; + $probe['target_ip'] = $targetIp; + $targetResult['last_probe'] = $probe; + if (($probe['ok'] ?? false) === true) { + $targetResult['ok'] = true; + break; + } + if ($attempt < self::GATEWAY_CERT_BOOTSTRAP_ATTEMPTS) { + sleep(self::GATEWAY_ROUTE_VERIFY_DELAY_SECONDS); + } + } + } catch (Throwable $throwable) { + $targetResult['error'] = $throwable->getMessage(); + } + + if (($targetResult['ok'] ?? false) !== true) { + $result['ok'] = false; + $result['warnings'][] = "Let's Encrypt certificate bootstrap failed for gateway target {$targetIp}."; + } + $result['results'][] = $targetResult; + } + } finally { + try { + $this->setLoadBalancerIpTargets($client, $loadBalancerId, $restoreTargetIps); + $result['restored'] = true; + } catch (Throwable $throwable) { + $result['ok'] = false; + $result['restore_error'] = $throwable->getMessage(); + $result['warnings'][] = 'Failed to restore Hetzner load balancer targets after certificate bootstrap: ' . $throwable->getMessage(); + } + } + + return $result; + } + + private function setLoadBalancerIpTargets(object $client, string $loadBalancerId, array $desiredIps): array + { + $desiredIps = array_values(array_unique(array_filter(array_map('strval', $desiredIps)))); + if ($desiredIps === []) { + throw new RuntimeException('At least one load balancer target must remain attached.'); + } + + $loadBalancer = $client->getLoadBalancer($loadBalancerId); + $currentIps = self::loadBalancerIpTargets($loadBalancer); + $actions = []; + + foreach (array_diff($desiredIps, $currentIps) as $ip) { + try { + $client->addIpTarget($loadBalancerId, $ip); + $actions[] = ['type' => 'add_target', 'target_ip' => $ip]; + } catch (hetzner_cloud_api_exception $exception) { + if ($exception->apiCode() !== 'target_already_defined') { + throw $exception; + } + $actions[] = ['type' => 'add_target', 'target_ip' => $ip, 'already_defined' => true]; + } + } + + $this->waitForLoadBalancerIpTargetsToInclude($client, $loadBalancerId, $desiredIps); + $loadBalancer = $client->getLoadBalancer($loadBalancerId); + $currentIps = self::loadBalancerIpTargets($loadBalancer); + + foreach (array_diff($currentIps, $desiredIps) as $ip) { + $client->removeIpTarget($loadBalancerId, $ip); + $actions[] = ['type' => 'remove_target', 'target_ip' => $ip]; + } + + $this->waitForLoadBalancerIpTargets($client, $loadBalancerId, $desiredIps); + return $actions; + } + + private function waitForLoadBalancerIpTargetsToInclude(object $client, string $loadBalancerId, array $requiredIps): void + { + $requiredIps = array_values(array_unique(array_filter(array_map('strval', $requiredIps)))); + for ($attempt = 1; $attempt <= self::GATEWAY_LOAD_BALANCER_TARGET_WAIT_ATTEMPTS; $attempt++) { + $currentIps = self::loadBalancerIpTargets($client->getLoadBalancer($loadBalancerId)); + if (array_diff($requiredIps, $currentIps) === []) { + return; + } + sleep(1); + } + + throw new RuntimeException('Timed out waiting for Hetzner load balancer targets to attach.'); + } + + private function waitForLoadBalancerIpTargets(object $client, string $loadBalancerId, array $desiredIps): void + { + $desiredIps = array_values(array_unique(array_filter(array_map('strval', $desiredIps)))); + sort($desiredIps); + + for ($attempt = 1; $attempt <= self::GATEWAY_LOAD_BALANCER_TARGET_WAIT_ATTEMPTS; $attempt++) { + $currentIps = self::loadBalancerIpTargets($client->getLoadBalancer($loadBalancerId)); + sort($currentIps); + if ($currentIps === $desiredIps) { + return; + } + sleep(1); + } + + throw new RuntimeException('Timed out waiting for Hetzner load balancer target changes.'); + } + + public function loadBalancerAutomationEnabled(): bool + { + $this->ensureSchema(); + $config = $this->loadBalancerConfig(); + return $config['automation_enabled'] + && $config['load_balancer_id'] !== '' + && $config['token_set']; + } + + private function loadBalancerReleaseApiTargets(): array + { + return array_values(array_filter( + $this->loadBalancerReleaseGatewayTargets(), + static fn(array $target): bool => self::gatewayRouteApp((string)($target['app'] ?? '')) === 'api' + )); + } + + private function loadBalancerReleaseGatewayTargets(): array + { + foreach (['release_deployment_targets', 'release_channels', 'release_deployments'] as $table) { + if (!$this->tableExists($table)) { + return []; + } + } + + return $this->selectRows( + "SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, + c.default_channel AS channel_default_channel, + d.id AS latest_deployment_id, d.status AS latest_deployment_status, + d.completed_at AS latest_deployment_completed_at, + d.commit_sha AS latest_deployment_commit_sha, + v.version_label AS latest_version_label + FROM release_deployment_targets t + INNER JOIN release_channels c ON c.id = t.channel_id + LEFT JOIN ( + SELECT d1.* + FROM release_deployments d1 + INNER JOIN ( + SELECT target_id, MAX(id) AS id + FROM release_deployments + WHERE status IN ('active', 'deployed') + GROUP BY target_id + ) latest ON latest.id = d1.id + ) d ON d.target_id = t.id + LEFT JOIN release_versions v ON v.id = d.version_id + WHERE t.deleted_at IS NULL + AND c.deleted_at IS NULL + AND c.enabled = 1 + AND t.app IN ('api', 'frontend') + AND t.coolify_instance_id IS NOT NULL + AND t.coolify_service_uuid IS NOT NULL + AND TRIM(t.coolify_service_uuid) <> '' + AND d.id IS NOT NULL + ORDER BY CASE WHEN d.status = 'active' THEN 0 WHEN d.status = 'deployed' THEN 1 ELSE 2 END, + d.completed_at DESC, t.id DESC" + ); + } + + private function provisionMissingGatewayRouteTargets( + array $uncoveredGatewayIps, + array $targets, + string $publicHost, + string $publicUrl, + bool $dryRun, + ?int $actorUserId + ): array { + $planned = []; + $applied = []; + $skipped = []; + $errors = []; + $warnings = []; + $coveredTargetIps = []; + $deploymentWaitItems = []; + $sourceTarget = $this->gatewayRouteProvisionSourceTarget($targets); + $app = self::gatewayRouteApp((string)($sourceTarget['app'] ?? $targets[0]['app'] ?? 'api')); + $sourcePublicUrl = $sourceTarget === null + ? $publicUrl + : self::gatewayRouteTargetPublicUrl($publicHost, $sourceTarget); + + foreach ($uncoveredGatewayIps as $targetIp) { + $action = [ + 'type' => 'provision_gateway_' . $app . '_target', + 'app' => $app, + 'target_ip' => $targetIp, + 'public_url' => $sourcePublicUrl, + 'dry_run' => $dryRun, + ]; + + if ($sourceTarget === null) { + $skipped[] = array_replace($action, ['reason' => 'missing_source_api_target']); + continue; + } + + $action['source_target_id'] = (int)($sourceTarget['id'] ?? 0); + $serverMatch = $this->gatewayRouteServerForTargetIp($sourceTarget, $targetIp); + if (($serverMatch['error'] ?? '') !== '') { + $errors[] = array_replace($action, ['error' => $serverMatch['error']]); + continue; + } + if (($serverMatch['ambiguous'] ?? false) === true) { + $skipped[] = array_replace($action, ['reason' => 'ambiguous_coolify_server', 'matches' => $serverMatch['matches'] ?? []]); + continue; + } + $server = is_array($serverMatch['server'] ?? null) ? $serverMatch['server'] : null; + $serverUuid = trim((string)($server['uuid'] ?? '')); + if ($server === null || $serverUuid === '') { + $skipped[] = array_replace($action, ['reason' => 'coolify_server_not_found']); + continue; + } + + $action['coolify_instance_id'] = (int)($serverMatch['instance_id'] ?? $sourceTarget['coolify_instance_id'] ?? 0); + $action['server_uuid'] = $serverUuid; + $action['server_name'] = $server['name'] ?? null; + $planned[] = $action; + + if ($dryRun) { + $coveredTargetIps[] = $targetIp; + continue; + } + + try { + if (!class_exists(release_manager::class) && function_exists('app_require')) { + app_require('classes/release_manager.php'); + } + if (!class_exists(release_manager::class)) { + throw new RuntimeException('Release Manager is required to auto-provision gateway release targets.'); + } + + $releaseManager = new release_manager(); + $deploymentTarget = $this->gatewayRouteExistingDeploymentTargetForServer($sourceTarget, $action['coolify_instance_id'], $serverUuid, $app); + if ($deploymentTarget === null) { + $deploymentTarget = $releaseManager->upsertDeploymentTarget([ + 'channel_id' => (int)$sourceTarget['channel_id'], + 'app' => $app, + 'coolify_instance_id' => $action['coolify_instance_id'], + 'coolify_service_uuid' => '', + 'repository' => (string)($sourceTarget['repository'] ?? ''), + 'branch' => (string)($sourceTarget['branch'] ?? 'master'), + 'auto_deploy' => !isset($sourceTarget['auto_deploy']) || (int)$sourceTarget['auto_deploy'] === 1, + 'health_url' => $app === 'api' ? $sourcePublicUrl . '/ping' : $sourcePublicUrl . '/release-entry.json', + 'deploy_context' => $this->gatewayRouteProvisionDeployContext($sourceTarget, $server, $targetIp, $publicHost, $sourcePublicUrl), + ], $actorUserId); + } + + $sourceCommitSha = trim((string)($sourceTarget['latest_deployment_commit_sha'] ?? '')); + $deploymentInput = [ + 'target_id' => (int)($deploymentTarget['id'] ?? 0), + 'channel_id' => (int)$sourceTarget['channel_id'], + 'app' => $app, + 'repository' => (string)($sourceTarget['repository'] ?? ''), + 'branch' => (string)($sourceTarget['branch'] ?? 'master'), + 'commit_mode' => $sourceCommitSha === '' ? 'latest' : 'specific', + 'version_label' => $this->gatewayRouteProvisionVersionLabel($sourceTarget), + 'deployed_url' => $sourcePublicUrl, + 'metadata' => [ + 'gateway_route_autoprovision' => true, + 'source_target_id' => (int)($sourceTarget['id'] ?? 0), + 'target_ip' => $targetIp, + 'server_uuid' => $serverUuid, + 'app' => $app, + ], + ]; + if ($sourceCommitSha !== '') { + $deploymentInput['commit_sha'] = $sourceCommitSha; + } + $deployment = $releaseManager->startDeployment($deploymentInput, $actorUserId); + + if ((string)($deployment['status'] ?? '') !== 'deployed') { + $errors[] = array_replace($action, [ + 'target_id' => (int)($deploymentTarget['id'] ?? 0), + 'deployment_id' => (int)($deployment['id'] ?? 0), + 'error' => (string)($deployment['error_message'] ?? 'Auto-provisioned release target deployment did not complete.'), + ]); + continue; + } + + $applied[] = array_replace($action, [ + 'target_id' => (int)($deploymentTarget['id'] ?? 0), + 'deployment_id' => (int)($deployment['id'] ?? 0), + 'status' => $deployment['status'] ?? null, + ]); + $deploymentResult = is_array($deployment['result'] ?? null) ? $deployment['result'] : []; + $deploymentWaitItems = array_merge( + $deploymentWaitItems, + self::coolifyDeploymentWaitItems($deploymentResult['deployment'] ?? [], $action['coolify_instance_id'], (string)($deploymentResult['service_uuid'] ?? ''), [ + 'target_id' => (int)($deploymentTarget['id'] ?? 0), + 'target_ip' => $targetIp, + 'resource_type' => (string)($deploymentResult['resource_type'] ?? 'application'), + ]) + ); + $coveredTargetIps[] = $targetIp; + } catch (Throwable $throwable) { + $errors[] = array_replace($action, ['error' => $throwable->getMessage()]); + } + } + + return [ + 'planned' => $planned, + 'applied' => $applied, + 'skipped' => $skipped, + 'errors' => $errors, + 'warnings' => $warnings, + 'covered_target_ips' => $coveredTargetIps, + 'deployment_wait_items' => $deploymentWaitItems, + ]; + } + + private function gatewayRouteProvisionSourceTarget(array $targets): ?array + { + foreach ($targets as $target) { + if ((int)($target['coolify_instance_id'] ?? 0) <= 0) { + continue; + } + if (trim((string)($target['repository'] ?? '')) === '') { + continue; + } + return $target; + } + + return null; + } + + private function gatewayRouteServerForTargetIp(array $sourceTarget, string $targetIp): array + { + $instanceId = (int)($sourceTarget['coolify_instance_id'] ?? 0); + if ($instanceId <= 0) { + return ['server' => null]; + } + + try { + $instance = $this->getInstance($instanceId); + $servers = $this->coolifyCollection($this->clientForInstance($instance)->listServers()); + } catch (Throwable $throwable) { + return ['server' => null, 'error' => $throwable->getMessage()]; + } + + $matches = []; + foreach ($servers as $server) { + if (!is_array($server)) { + continue; + } + if (!self::gatewayRouteServerIsUsable($server)) { + continue; + } + if (self::gatewayRouteServerPublicIp($server) === $targetIp) { + $matches[] = $server; + } + } + + if (count($matches) > 1) { + return [ + 'server' => null, + 'ambiguous' => true, + 'matches' => array_map(static fn(array $server): array => [ + 'uuid' => $server['uuid'] ?? null, + 'name' => $server['name'] ?? null, + 'ip' => self::gatewayRouteServerPublicIp($server), + ], $matches), + ]; + } + + return [ + 'server' => $matches[0] ?? null, + 'instance_id' => $instanceId, + ]; + } + + private function gatewayRouteExistingDeploymentTargetForServer(array $sourceTarget, int $instanceId, string $serverUuid, string $app = 'api'): ?array + { + if ($instanceId <= 0 || $serverUuid === '') { + return null; + } + + $rows = $this->selectRows( + "SELECT t.*, c.slug AS channel_slug, c.name AS channel_name + FROM release_deployment_targets t + INNER JOIN release_channels c ON c.id = t.channel_id + WHERE t.deleted_at IS NULL + AND t.channel_id = ? + AND t.app = ? + AND t.coolify_instance_id = ? + AND t.repository = ? + AND t.branch = ? + ORDER BY t.id DESC", + 'isiss', + [ + (int)$sourceTarget['channel_id'], + self::gatewayRouteApp($app), + $instanceId, + (string)($sourceTarget['repository'] ?? ''), + (string)($sourceTarget['branch'] ?? ''), + ] + ); + + foreach ($rows as $row) { + $context = self::jsonDecode($row['deploy_context_json'] ?? null); + $candidateUuid = trim((string)($context['coolify_server_uuid'] ?? $context['server_uuid'] ?? '')); + if ($candidateUuid === $serverUuid) { + return $row; + } + } + + return null; + } + + private function gatewayRouteProvisionDeployContext(array $sourceTarget, array $server, string $targetIp, string $publicHost, string $publicUrl): array + { + $context = self::jsonDecode($sourceTarget['deploy_context_json'] ?? null); + $serverUuid = trim((string)($server['uuid'] ?? '')); + $channelSlug = self::gatewayRouteSlug((string)($sourceTarget['channel_slug'] ?? $sourceTarget['channel_id'] ?? 'release'), 'release'); + $app = self::gatewayRouteApp((string)($sourceTarget['app'] ?? 'api')); + $serverSlug = self::gatewayRouteSlug((string)($server['name'] ?? $targetIp), 'server'); + $serviceName = substr('release-' . $channelSlug . '-' . $app . '-' . $serverSlug, 0, 64); + + $context['coolify_auto_create'] = true; + $context['coolify_enable_ssl'] = true; + $context['coolify_deploy_now'] = true; + $context['coolify_ports_exposes'] = '80'; + $context['coolify_port'] = '80'; + $context['coolify_domain'] = $publicHost; + $context['coolify_public_url'] = $publicUrl; + $context['coolify_server_uuid'] = $serverUuid; + $context['server_uuid'] = $serverUuid; + $context['coolify_destination_uuid'] = ''; + $context['destination_uuid'] = ''; + if ($app === 'api') { + $context['coolify_build_pack'] = 'dockerfile'; + $context['coolify_dockerfile_location'] = '/Dockerfile.coolify-api'; + } else { + $context['coolify_build_pack'] = 'dockerfile'; + $context['coolify_dockerfile_location'] = '/Dockerfile.coolify-frontend'; + unset( + $context['coolify_install_command'], + $context['install_command'], + $context['coolify_build_command'], + $context['build_command'], + $context['coolify_publish_directory'], + $context['publish_directory'], + $context['coolify_is_static'], + $context['is_static'], + $context['coolify_is_spa'], + $context['is_spa'] + ); + } + + unset( + $context['coolify_base_directory'], + $context['base_directory'], + $context['coolify_docker_compose_location'], + $context['docker_compose_location'], + $context['coolify_dockerfile'], + $context['dockerfile'], + $context['coolify_git_commit_sha'], + $context['git_commit_sha'], + $context['commit_sha'], + $context['commit'], + $context['coolify_start_command'], + $context['start_command'] + ); + if ($app === 'api' || $app === 'frontend') { + unset( + $context['coolify_is_static'], + $context['is_static'], + $context['coolify_is_spa'], + $context['is_spa'], + $context['coolify_publish_directory'], + $context['publish_directory'] + ); + } + $context['coolify_service_name'] = $serviceName; + $context['coolify_application_name'] = $serviceName; + $context['gateway_route_autoprovision'] = true; + $context['gateway_route_source_target_id'] = (int)($sourceTarget['id'] ?? 0); + $context['gateway_route_target_ip'] = $targetIp; + + return $context; + } + + private function gatewayRouteProvisionVersionLabel(array $sourceTarget): string + { + $label = trim((string)($sourceTarget['latest_version_label'] ?? $sourceTarget['version_label'] ?? '')); + if ($label !== '') { + return $label; + } + + $channelSlug = self::gatewayRouteSlug((string)($sourceTarget['channel_slug'] ?? 'release'), 'release'); + $app = self::gatewayRouteApp((string)($sourceTarget['app'] ?? 'api')); + return $channelSlug . '-' . $app . '-gateway-' . date('Ymd-His'); + } + + private static function gatewayRouteApp(string $app): string + { + $app = strtolower(trim($app)); + return in_array($app, ['api', 'frontend'], true) ? $app : 'api'; + } + + private static function gatewayRouteServerIsUsable(array $server): bool + { + $settings = is_array($server['settings'] ?? null) ? $server['settings'] : []; + return ($settings['is_reachable'] ?? true) !== false && ($settings['is_usable'] ?? true) !== false; + } + + private static function gatewayRouteServerPublicIp(array $server): ?string + { + foreach ([ + 'public_ip', + 'publicIp', + 'public_ipv4', + 'publicIpv4', + 'ip', + 'address', + 'hostname', + 'fqdn', + 'domain', + 'name', + ] as $key) { + $ip = self::publicIpFromHost($server[$key] ?? null); + if ($ip !== null) { + return $ip; + } + } + + return self::publicIpFromHost(self::publicServerHostFromCoolifyServer($server)); + } + + private static function gatewayRouteSlug(string $value, string $fallback): string + { + $slug = strtolower(trim($value)); + $slug = preg_replace('/[^a-z0-9-]+/', '-', $slug) ?: ''; + $slug = trim($slug, '-'); + return substr($slug !== '' ? $slug : $fallback, 0, 40); + } + + private static function gatewayRouteTargetPublicUrl(string $publicHost, array $target): string + { + $baseUrl = 'https://' . strtolower(trim($publicHost)); + $channelSlug = self::gatewayRouteSlug((string)($target['channel_slug'] ?? ''), ''); + $appSlug = self::gatewayRouteSlug((string)($target['app'] ?? 'api'), 'api'); + $defaultChannel = (int)($target['channel_default_channel'] ?? $target['default_channel'] ?? 0) === 1 + || $channelSlug === 'stable' + || $channelSlug === ''; + + if ($defaultChannel || !in_array($appSlug, ['api', 'frontend'], true)) { + return $baseUrl; + } + + return $baseUrl . '/' . $channelSlug . '/' . $appSlug; + } + + private function gatewayRouteResourceType(array $target): string + { + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + $type = strtolower(trim((string)($context['coolify_resource_type'] ?? $context['resource_type'] ?? ''))); + if (in_array($type, ['service', 'docker-compose', 'compose'], true)) { + return 'service'; + } + + return 'application'; + } + + private static function resourceServerIp(array $resource): ?string + { + $servers = []; + foreach ([ + $resource['destination']['server'] ?? null, + $resource['server'] ?? null, + $resource['server_details'] ?? null, + ] as $server) { + if (is_array($server)) { + $servers[] = $server; + } + } + + foreach ($servers as $server) { + $host = self::publicServerHostFromCoolifyServer($server); + $ip = self::publicIpFromHost($host); + if ($ip !== null) { + return $ip; + } + } + + foreach ([ + 'public_ip', + 'publicIp', + 'public_ipv4', + 'publicIpv4', + 'server_ip', + 'serverIp', + 'ip', + ] as $key) { + $ip = self::publicIpFromHost($resource[$key] ?? null); + if ($ip !== null) { + return $ip; + } + } + + return null; + } + + private static function resourcePublicUrl(array $resource): ?string + { + foreach (['fqdn', 'domains', 'domain', 'url'] as $key) { + $url = self::firstPublicUrl($resource[$key] ?? null); + if ($url !== null) { + return $url; + } + } + + return self::firstPublicUrl($resource['urls'] ?? null); + } + + private static function gatewayRouteApplicationPayload( + string $publicUrl, + string $resourceUuid = '', + ?int $port = null, + mixed $existingLabels = null + ): array + { + $decodedLabels = self::decodeCoolifyLabels($existingLabels); + $routePort = $port ?? self::coolifyLabelFirstServicePort($decodedLabels, $resourceUuid); + $payload = [ + 'domains' => self::coolifyProxyUrl($publicUrl, $routePort), + 'is_force_https_enabled' => true, + 'force_domain_override' => true, + ]; + + $labels = self::gatewayRouteApplicationLabels( + $publicUrl, + $resourceUuid, + $routePort, + self::gatewayRouteDefaultCertResolver($publicUrl) + ); + if ($labels !== []) { + $payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels( + $decodedLabels, + $labels + ))); + } + + return $payload; + } + + private static function gatewayRouteServicePayload(string $publicUrl, string $app, ?int $port = null): array + { + return [ + 'urls' => [ + [ + 'name' => trim($app) !== '' ? $app : 'api', + 'url' => self::coolifyProxyUrl($publicUrl, $port), + ], + ], + 'force_domain_override' => true, + ]; + } + + private static function coolifyProxyUrl(string $publicUrl, ?int $port): string + { + if ($port === null || $port <= 0) { + return $publicUrl; + } + + $parts = parse_url($publicUrl); + if (!is_array($parts) || trim((string)($parts['host'] ?? '')) === '' || isset($parts['port'])) { + return $publicUrl; + } + + $scheme = trim((string)($parts['scheme'] ?? 'https')) ?: 'https'; + $host = trim((string)$parts['host']); + $path = (string)($parts['path'] ?? ''); + $query = isset($parts['query']) ? '?' . $parts['query'] : ''; + $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : ''; + return "{$scheme}://{$host}:{$port}{$path}{$query}{$fragment}"; + } + + private static function gatewayRouteApplicationLabels( + string $publicUrl, + string $resourceUuid, + ?int $port = null, + ?string $certResolver = null + ): array + { + $resourceUuid = self::gatewayRouteLabelId($resourceUuid); + if ($resourceUuid === '') { + return []; + } + + $parts = parse_url($publicUrl); + $host = trim((string)($parts['host'] ?? '')); + if ($host === '') { + return []; + } + + $scheme = strtolower((string)($parts['scheme'] ?? 'https')); + $path = trim((string)($parts['path'] ?? '/')); + $path = $path !== '' ? $path : '/'; + if ($path[0] !== '/') { + $path = '/' . $path; + } + + $routePort = $port ?? self::firstInteger($parts['port'] ?? null); + $certResolver = trim((string)($certResolver ?? '')); + $httpLabel = 'http-0-' . $resourceUuid; + $httpsLabel = 'https-0-' . $resourceUuid; + $labels = [ + 'traefik.enable=true', + 'traefik.http.middlewares.gzip.compress=true', + 'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https', + ]; + + if ($scheme === 'https') { + $labels[] = "traefik.http.routers.{$httpsLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"; + $labels[] = "traefik.http.routers.{$httpsLabel}.entryPoints=https"; + if ($routePort !== null) { + $labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}"; + $labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}"; + } + if ($path !== '/') { + $labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}"; + $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip"; + } else { + $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip"; + } + $labels[] = "traefik.http.routers.{$httpsLabel}.tls=true"; + if ($certResolver !== '') { + $labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}"; + } + $labels[] = "traefik.http.routers.{$httpsLabel}.tls.domains[0].main={$host}"; + $labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"; + $labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http"; + if ($routePort !== null) { + $labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}"; + $labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}"; + } + $labels[] = "traefik.http.routers.{$httpLabel}.middlewares=redirect-to-https"; + } else { + $labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"; + $labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http"; + if ($routePort !== null) { + $labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}"; + $labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}"; + } + if ($path !== '/') { + $labels[] = "traefik.http.middlewares.{$httpLabel}-stripprefix.stripprefix.prefixes={$path}"; + $labels[] = "traefik.http.routers.{$httpLabel}.middlewares={$httpLabel}-stripprefix,gzip"; + } else { + $labels[] = "traefik.http.routers.{$httpLabel}.middlewares=gzip"; + } + } + + sort($labels); + return $labels; + } + + private static function mergeCoolifyLabels(array $existingLabels, array $generatedLabels): array + { + $merged = []; + foreach (array_merge($existingLabels, $generatedLabels) as $label) { + $label = trim((string)$label); + if ($label === '') { + continue; + } + $merged[self::coolifyLabelKey($label)] = $label; + } + + return array_values($merged); + } + + private static function coolifyLabelFirstServicePort(array $labels, string $resourceUuid): ?int + { + $resourceUuid = self::gatewayRouteLabelId($resourceUuid); + $fallback = null; + foreach ($labels as $label) { + if (preg_match('/^traefik\.http\.services\.([^=]+)\.loadbalancer\.server\.port=(\d+)$/', trim((string)$label), $matches) !== 1) { + continue; + } + $port = (int)$matches[2]; + if ($port <= 0) { + continue; + } + if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) { + return $port; + } + $fallback ??= $port; + } + + return $fallback; + } + + private static function coolifyLabelCertResolver(array $labels, string $resourceUuid): ?string + { + $resourceUuid = self::gatewayRouteLabelId($resourceUuid); + $fallback = null; + foreach ($labels as $label) { + if (preg_match('/^traefik\.http\.routers\.([^=]+)\.tls\.certresolver=([A-Za-z0-9_.-]+)$/', trim((string)$label), $matches) !== 1) { + continue; + } + $resolver = trim((string)$matches[2]); + if ($resolver === '') { + continue; + } + if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) { + return $resolver; + } + $fallback ??= $resolver; + } + + return $fallback; + } + + private static function gatewayRouteDefaultCertResolver(string $publicUrl): string + { + return 'letsencrypt'; + } + + private static function decodeCoolifyLabels(mixed $labels): array + { + if (!is_scalar($labels)) { + return []; + } + + $raw = trim((string)$labels); + if ($raw === '') { + return []; + } + + $decoded = base64_decode($raw, true); + $content = $decoded !== false ? $decoded : $raw; + return array_values(array_filter( + preg_split('/\r\n|\r|\n/', (string)$content) ?: [], + static fn(string $label): bool => trim($label) !== '' + )); + } + + private static function coolifyLabelKey(string $label): string + { + $position = strpos($label, '='); + return $position === false ? trim($label) : trim(substr($label, 0, $position)); + } + + private static function gatewayRouteLabelId(string $value): string + { + $value = strtolower(trim($value)); + $value = preg_replace('/[^a-z0-9-]+/', '-', $value) ?: ''; + return trim($value, '-'); + } + + private static function resourceFirstExposedPort(array $resource, array $target = []): ?int + { + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + foreach ([ + $resource['ports_exposes'] ?? null, + $resource['portsExposes'] ?? null, + $context['coolify_ports_exposes'] ?? null, + $context['ports_exposes'] ?? null, + $context['coolify_port'] ?? null, + $context['port'] ?? null, + ] as $value) { + $port = self::firstInteger($value); + if ($port !== null) { + return $port; + } + } + + return null; + } + + private static function firstInteger(mixed $value): ?int + { + if (is_int($value)) { + return $value > 0 ? $value : null; + } + if (is_float($value)) { + return $value > 0 ? (int)$value : null; + } + if (is_array($value)) { + foreach ($value as $item) { + $integer = self::firstInteger($item); + if ($integer !== null) { + return $integer; + } + } + return null; + } + if (!is_scalar($value)) { + return null; + } + if (preg_match('/\d+/', (string)$value, $matches) !== 1) { + return null; + } + + $integer = (int)$matches[0]; + return $integer > 0 ? $integer : null; + } + + private function persistGatewayRouteTargetContext(int $targetId, array $target, string $publicHost, string $publicUrl): void + { + if ($targetId <= 0) { + return; + } + + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + $context['coolify_enable_ssl'] = true; + $context['coolify_domain'] = $publicHost; + $context['coolify_public_url'] = $publicUrl; + + $this->execute( + 'UPDATE release_deployment_targets SET deploy_context_json = ? WHERE id = ?', + 'si', + [self::jsonEncode($context), $targetId] + ); + + if (!$this->tableExists('release_deployments')) { + return; + } + + $deployment = $this->selectOne( + "SELECT id FROM release_deployments + WHERE target_id = ? AND app = 'api' AND status IN ('active', 'deployed') + ORDER BY id DESC + LIMIT 1", + 'i', + [$targetId] + ); + if ($deployment === null) { + return; + } + + $this->execute( + 'UPDATE release_deployments SET deployment_url = ? WHERE id = ?', + 'si', + [$publicUrl, (int)$deployment['id']] + ); + } + + private function shouldRetryProvisioning(array $target, ?array $host = null): bool + { + if (trim((string)($target['resource_uuid'] ?? '')) === '') { + return false; + } + + if (in_array((string)($target['deployment_status'] ?? ''), ['created', 'deploying', 'provision_blocked'], true)) { + return true; + } + + return $host !== null && self::replicationHostStillNeedsProvisioning($host); + } + + private function hasRunningReplicationProvisionOperation(string $kind, int $hostId): bool + { + if ($hostId <= 0) { + return false; + } + + return $this->selectOne( + "SELECT id FROM replication_operations + WHERE kind = ? AND host_id = ? AND operation = 'provision' AND status = 'running' + LIMIT 1", + 'si', + [$kind, $hostId] + ) !== null; + } + + private static function replicationHostStillNeedsProvisioning(array $host): bool + { + if ((string)($host['role'] ?? '') === 'primary') { + return false; + } + + $status = self::jsonDecode($host['last_status_json'] ?? null); + $effectiveStatus = (string)($status['status'] ?? $host['status'] ?? 'unknown'); + $percent = round((float)($status['replication_percent'] ?? 0), 2); + $blockers = array_values(array_filter($status['blockers'] ?? [])); + + return $effectiveStatus !== 'ok' || $percent < 100.0 || $blockers !== []; + } + + private function getTargetDeploymentStatus(int $targetId): string + { + try { + $target = $this->selectOne('SELECT deployment_status FROM coolify_targets WHERE id = ? LIMIT 1', 'i', [$targetId]); + return (string)($target['deployment_status'] ?? 'unknown'); + } catch (Throwable) { + return 'unknown'; + } + } + + public static function parseEnvFile(string $env): array + { + $values = []; + foreach (preg_split('/\r\n|\r|\n/', $env) ?: [] as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) { + continue; + } + [$key, $value] = explode('=', $line, 2); + $key = trim($key); + if ($key === '') { + continue; + } + $values[$key] = trim($value); + } + return $values; + } + + public static function blocksPrimaryMutation(array $host, string $operation): bool + { + return in_array($operation, ['deploy', 'restart', 'delete', 'stop', 'replace'], true) + && (string)($host['role'] ?? '') === 'primary'; + } + + public static function targetAllowsReplicaRemoval(?array $target): bool + { + if ($target === null || (string)($target['role'] ?? $target['replication_role'] ?? '') === 'primary') { + return false; + } + + $deploymentStatus = (string)($target['deployment_status'] ?? ''); + $lastReconcileStatus = (string)($target['last_reconcile_status'] ?? ''); + if (in_array($deploymentStatus, ['reconcile_failed', 'removed', 'delete_failed'], true) + || in_array($lastReconcileStatus, ['reconcile_failed', 'delete_failed'], true)) { + return true; + } + + $lastReconcile = self::jsonDecode($target['last_reconcile_json'] ?? null); + $message = strtolower((string)($lastReconcile['message'] ?? $lastReconcile['error'] ?? '')); + return $message !== '' && (str_contains($message, 'not found') || str_contains($message, '404')); + } + + public static function replicationHostCanBeRemoved(array $host): bool + { + if ((string)($host['role'] ?? '') === 'primary') { + return false; + } + + $hostId = (int)($host['id'] ?? 0); + if ($hostId <= 0) { + return false; + } + + try { + if (!coolify_schema_bootstrap::tablesExist()) { + return false; + } + + $manager = new self(); + $target = $manager->selectOne( + 'SELECT * FROM coolify_targets WHERE replication_host_id = ? AND deleted_at IS NULL ORDER BY id DESC LIMIT 1', + 'i', + [$hostId] + ); + if ($target === null) { + return self::hostHasCoolifyMetadata($host); + } + + return self::targetAllowsReplicaRemoval($target); + } catch (Throwable) { + return false; + } + } + + public static function markTargetsRemovedForReplicationHost(int $hostId, ?int $actorUserId = null): void + { + if ($hostId <= 0) { + return; + } + + try { + if (!coolify_schema_bootstrap::tablesExist()) { + return; + } + + $manager = new self(); + $targets = $manager->selectRows( + 'SELECT id, instance_id, replication_host_id FROM coolify_targets WHERE replication_host_id = ? AND deleted_at IS NULL', + 'i', + [$hostId] + ); + if ($targets === []) { + return; + } + + $manager->execute( + "UPDATE coolify_targets + SET deleted_at = NOW(), deployment_status = 'removed', availability_state = 'degraded' + WHERE replication_host_id = ? AND deleted_at IS NULL", + 'i', + [$hostId] + ); + + foreach ($targets as $target) { + $manager->audit( + (int)$target['id'], + (int)$target['instance_id'], + (int)$target['replication_host_id'], + 'target_removed_with_replication_host', + $actorUserId, + 'warning', + ['host_id' => $hostId] + ); + } + } catch (Throwable) { + // Removing the replication host should not be blocked by optional Coolify metadata cleanup. + } + } + + public static function deploymentMetadataForReplicationHost(int $hostId): ?array + { + if ($hostId <= 0) { + return null; + } + + try { + global $db; + if (!coolify_schema_bootstrap::tablesExist()) { + return null; + } + $stmt = $db->prepare( + "SELECT t.*, i.label AS instance_label, i.base_url AS instance_base_url + FROM coolify_targets t + INNER JOIN coolify_instances i ON i.id = t.instance_id + WHERE t.replication_host_id = ? AND t.deleted_at IS NULL + ORDER BY t.id DESC LIMIT 1" + ); + if ($stmt === false) { + return null; + } + $stmt->bind_param('i', $hostId); + $stmt->execute(); + $result = $stmt->get_result(); + $target = $result ? $result->fetch_assoc() : null; + if (!is_array($target)) { + return null; + } + + return [ + 'target_id' => (int)$target['id'], + 'instance_id' => (int)$target['instance_id'], + 'instance_label' => (string)($target['instance_label'] ?? ''), + 'base_url' => (string)($target['instance_base_url'] ?? ''), + 'server_uuid' => $target['server_uuid'] ?? null, + 'project_uuid' => $target['project_uuid'] ?? null, + 'environment_uuid' => $target['environment_uuid'] ?? null, + 'environment_name' => $target['environment_name'] ?? null, + 'destination_uuid' => $target['destination_uuid'] ?? null, + 'resource_uuid' => $target['resource_uuid'] ?? null, + 'resource_type' => (string)($target['resource_type'] ?? self::RESOURCE_TYPE_SERVICE), + 'resource_name' => $target['resource_name'] ?? null, + 'deployment_status' => (string)($target['deployment_status'] ?? 'unknown'), + 'last_reconcile_status' => $target['last_reconcile_status'] ?? null, + 'last_reconciled_at' => $target['last_reconciled_at'] ?? null, + 'availability_state' => (string)($target['availability_state'] ?? 'degraded'), + ]; + } catch (Throwable) { + return null; + } + } + + public static function syncDeploymentStateForReplicationHost(int $hostId): void + { + if ($hostId <= 0) { + return; + } + + try { + if (!coolify_schema_bootstrap::tablesExist()) { + return; + } + + (new self())->syncTargetsForReplicationHost($hostId); + } catch (Throwable) { + // Replication health checks must not fail just because Coolify metadata cannot be updated. + } + } + + public static function syncLabelForReplicationHost(int $hostId, string $label): void + { + if ($hostId <= 0 || trim($label) === '') { + return; + } + + try { + if (!coolify_schema_bootstrap::tablesExist()) { + return; + } + + (new self())->execute( + 'UPDATE coolify_targets SET label = ? WHERE replication_host_id = ? AND deleted_at IS NULL', + 'si', + [$label, $hostId] + ); + } catch (Throwable) { + // Renaming a replication host should not fail because optional Coolify metadata is unavailable. + } + } + + private function syncTargetsForReplicationHost(int $hostId): void + { + $host = $this->replicationHost($hostId, true); + $availabilityState = $this->availabilityStateForHost($host); + $hostIsReady = $this->replicationHostIsReady($host); + + foreach ($this->selectRows( + 'SELECT id, deployment_status FROM coolify_targets WHERE replication_host_id = ? AND deleted_at IS NULL', + 'i', + [$hostId] + ) as $target) { + $deploymentStatus = (string)($target['deployment_status'] ?? 'unknown'); + $nextDeploymentStatus = $deploymentStatus; + if ($hostIsReady && in_array($deploymentStatus, ['pending', 'created', 'deploying', 'provision_blocked', 'restarting'], true)) { + $nextDeploymentStatus = 'provisioned'; + } + + $this->execute( + 'UPDATE coolify_targets SET availability_state = ?, deployment_status = ? WHERE id = ?', + 'ssi', + [$availabilityState, $nextDeploymentStatus, (int)$target['id']] + ); + } + } + + private function ensureSchema(): void + { + if ($this->schemaEnsured) { + return; + } + + coolify_schema_bootstrap::ensureTables(); + $this->schemaEnsured = true; + } + + private function composeInputFromRequest(string $kind, array $input, array $instance): array + { + $composeRole = $this->isIsolatedStackTargetRequest($input) ? 'primary' : 'replica'; + $hostPort = (int)($input['host_port'] ?? $input['port'] ?? match ($kind) { + 'database' => 3307, + 'redis' => 6380, + default => 9010, + }); + + $base = [ + 'kind' => $kind, + 'role' => $composeRole, + 'service_name' => $input['service_name'] ?? $input['resource_name'] ?? null, + 'host_port' => $hostPort, + ]; + + if ($kind === 'database') { + $base['database'] = (string)($input['database'] ?? $input['database_name'] ?? 'nnks_db'); + $base['username'] = (string)($input['username'] ?? 'nnks_db_user'); + $base['server_id'] = (int)($input['server_id'] ?? max(2, time() % 4294967295)); + [$base['primary_host'], $base['primary_port']] = $this->primaryAddress('database'); + } elseif ($kind === 'redis') { + [$base['primary_host'], $base['primary_port']] = $this->primaryAddress('redis'); + } else { + $base['host'] = (string)($input['host'] ?? ''); + $base['scheme'] = (string)($input['scheme'] ?? 'http'); + $base['console_port'] = (int)($input['console_port'] ?? ($hostPort + 1)); + $base['buckets'] = $this->normalizeBuckets($input['buckets'] ?? null); + $base['replication_transfer_limit'] = (string)($input['replication_transfer_limit'] + ?? ($input['options']['replication_transfer_limit'] ?? '')); + [$base['primary_host'], $base['primary_port']] = $this->primaryAddress('minio'); + } + + return $base; + } + + private function hostPayloadFromTemplate(string $kind, array $input, array $template): array + { + $credentials = is_array($template['credentials'] ?? null) ? $template['credentials'] : []; + $host = trim((string)($input['host'] ?? $input['endpoint'] ?? '')); + if ($host === '') { + throw new RuntimeException('Target host is required so replication can reach the Coolify-managed container.'); + } + $isolatedStack = $this->isIsolatedStackTargetRequest($input); + + $payload = [ + 'label' => trim((string)($input['label'] ?? $credentials['label'] ?? $template['service_name'] ?? '')), + 'host' => $host, + 'port' => (int)($credentials['port'] ?? $input['port'] ?? $template['host_port'] ?? 0), + 'username' => (string)($credentials['username'] ?? $input['username'] ?? ''), + 'password' => (string)($credentials['password'] ?? $input['password'] ?? ''), + ]; + + if ($kind === 'database') { + $payload['database'] = (string)($credentials['database'] ?? $input['database'] ?? $input['database_name'] ?? ''); + $payload['admin_username'] = (string)($credentials['admin_username'] ?? $input['admin_username'] ?? 'root'); + $payload['admin_password'] = (string)($credentials['admin_password'] ?? $input['admin_password'] ?? ''); + $payload['replication_username'] = (string)($credentials['replication_username'] ?? $input['replication_username'] ?? 'replication'); + $payload['replication_password'] = (string)($credentials['replication_password'] ?? $input['replication_password'] ?? ''); + $payload['ssl_mode'] = (string)($credentials['ssl_mode'] ?? $input['ssl_mode'] ?? 'DISABLED'); + $payload['options'] = [ + 'allow_preseeded_replica' => !$isolatedStack, + 'isolated_stack' => $isolatedStack, + 'skip_replication_provisioning' => $isolatedStack, + 'production_data_attached' => false, + ]; + } elseif ($kind === 'redis') { + $payload['database'] = (int)($credentials['database'] ?? $input['database'] ?? 0); + $payload['options'] = [ + 'isolated_stack' => $isolatedStack, + 'skip_replication_provisioning' => $isolatedStack, + 'production_data_attached' => false, + ]; + } else { + $payload['scheme'] = (string)($credentials['scheme'] ?? $input['scheme'] ?? 'http'); + $payload['buckets'] = $credentials['buckets'] ?? $this->normalizeBuckets($input['buckets'] ?? null); + $payload['console_port'] = (int)($credentials['console_port'] ?? $input['console_port'] ?? 9001); + $payload['replication_transfer_limit'] = (string)($credentials['replication_transfer_limit'] + ?? $input['replication_transfer_limit'] + ?? ($input['options']['replication_transfer_limit'] ?? '')); + $payload['options'] = [ + 'scheme' => $payload['scheme'], + 'buckets' => $payload['buckets'], + 'console_port' => $payload['console_port'], + 'replication_transfer_limit' => $payload['replication_transfer_limit'], + 'space_headroom_percent' => (float)($credentials['space_headroom_percent'] ?? 20.0), + 'isolated_stack' => $isolatedStack, + 'skip_replication_provisioning' => $isolatedStack, + 'production_data_attached' => false, + ]; + } + + return $payload; + } + + private function composeTemplateForTarget(array $target, array $host): array + { + $kind = (string)$target['kind']; + $options = self::jsonDecode($target['options_json'] ?? null); + $credentials = $this->hostCredentials($host); + $input = is_array($options['compose_input'] ?? null) ? $options['compose_input'] : []; + $input['kind'] = $kind; + $input['role'] = $this->targetComposeRole($target, $options); + $input['service_name'] = $target['resource_name'] ?? $target['label'] ?? null; + $input['host_port'] = (int)($host['port'] ?? $input['host_port'] ?? 0); + + if ($kind === 'database') { + $input['database'] = (string)($host['database_name'] ?? $input['database'] ?? ''); + $input['username'] = (string)($host['username'] ?? $input['username'] ?? ''); + $input['password'] = $credentials['password']; + $input['admin_username'] = $credentials['admin_username'] ?: 'root'; + $input['admin_password'] = $credentials['admin_password']; + $input['replication_username'] = $credentials['replication_username'] ?: 'replication'; + $input['replication_password'] = $credentials['replication_password']; + [$input['primary_host'], $input['primary_port']] = $this->primaryAddress('database'); + $primaryCredentials = $this->primaryCredentials('database'); + if ($primaryCredentials !== []) { + $input['primary_admin_username'] = $primaryCredentials['admin_username'] + ?: ($primaryCredentials['username'] ?: 'root'); + $input['primary_admin_password'] = $primaryCredentials['admin_password'] + ?: $primaryCredentials['password']; + } + } elseif ($kind === 'redis') { + $input['password'] = $credentials['password']; + [$input['primary_host'], $input['primary_port']] = $this->primaryAddress('redis'); + } else { + $hostOptions = self::jsonDecode($host['options_json'] ?? null); + $input['host'] = (string)($host['host'] ?? $input['host'] ?? ''); + $input['scheme'] = (string)($hostOptions['scheme'] ?? $input['scheme'] ?? 'http'); + $input['username'] = $credentials['username']; + $input['password'] = $credentials['password']; + $input['buckets'] = $hostOptions['buckets'] ?? $input['buckets'] ?? []; + $input['console_port'] = (int)($hostOptions['console_port'] ?? $input['console_port'] ?? 9001); + $input['replication_transfer_limit'] = (string)($hostOptions['replication_transfer_limit'] + ?? $input['replication_transfer_limit'] + ?? ''); + [$input['primary_host'], $input['primary_port']] = $this->primaryAddress('minio'); + } + + return replication_manager::composeTemplate($input); + } + + private function primaryCredentials(string $kind): array + { + $primary = $this->selectOne( + "SELECT * FROM replication_hosts WHERE kind = ? AND role = 'primary' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1", + 's', + [$kind] + ); + if ($primary === null) { + return []; + } + + return $this->hostCredentials($primary); + } + + private function startOrRestartService(coolify_api_client $client, string $resourceUuid, bool $restartIfRunning): array + { + try { + return $client->startService($resourceUuid); + } catch (Throwable $throwable) { + if (!str_contains(strtolower($throwable->getMessage()), 'already running')) { + throw $throwable; + } + + if ($restartIfRunning) { + return array_replace( + ['already_running' => true, 'action' => 'restart_requested'], + $client->restartService($resourceUuid) + ); + } + + return [ + 'already_running' => true, + 'action' => 'start_noop', + 'message' => 'Service is already running.', + ]; + } + } + + private function recordCreatedResource(int $targetId, string $resourceUuid, string $hash): void + { + $this->execute( + "UPDATE coolify_targets + SET resource_uuid = ?, deployment_status = 'created', desired_compose_hash = ?, + last_reconcile_status = 'created', last_reconciled_at = NOW() + WHERE id = ?", + 'ssi', + [$resourceUuid, $hash, $targetId] + ); + } + + private function servicePayload(array $target, array $template, bool $update): array + { + $payload = [ + 'name' => (string)($target['resource_name'] ?? $target['label']), + 'description' => 'Truckwash managed ' . $target['kind'] . ' replication target. Do not stop the active primary here.', + 'instant_deploy' => false, + 'docker_compose_raw' => $this->encodedDockerCompose($template), + 'force_domain_override' => false, + ]; + + if (!$update) { + $payload = array_replace($payload, [ + 'project_uuid' => $target['project_uuid'] ?? null, + 'environment_name' => $target['environment_name'] ?: 'production', + 'environment_uuid' => $target['environment_uuid'] ?? null, + 'server_uuid' => $target['server_uuid'] ?? null, + 'destination_uuid' => $target['destination_uuid'] ?? null, + ]); + } + + return array_filter($payload, static fn($value): bool => $value !== null && $value !== ''); + } + + private function encodedDockerCompose(array $template): string + { + return base64_encode((string)($template['compose'] ?? '')); + } + + private function targetOptions(array $input, array $template, array $composeInput): array + { + $isolatedStack = $this->isIsolatedStackTargetRequest($input); + $options = [ + 'compose_input' => $composeInput, + 'compose_role' => (string)($composeInput['role'] ?? 'replica'), + 'compose_service_name' => (string)($template['service_name'] ?? ''), + 'engine' => (string)($template['engine'] ?? ''), + 'coolify_docs' => [ + 'services_endpoint' => '/api/v1/services', + 'envs_bulk_endpoint' => '/api/v1/services/{uuid}/envs/bulk', + ], + ]; + + if ($isolatedStack) { + $options['isolated_stack'] = true; + $options['skip_replication_provisioning'] = true; + $options['production_data_attached'] = false; + } + + return $options; + } + + private function isIsolatedStackTargetRequest(array $input): bool + { + $options = is_array($input['options'] ?? null) ? $input['options'] : []; + + return $this->toBool( + $input['isolated_stack'] + ?? $input['isolated_empty_service'] + ?? $input['skip_replication_provisioning'] + ?? $options['isolated_stack'] + ?? $options['skip_replication_provisioning'] + ?? false, + false + ); + } + + private function targetComposeRole(array $target, array $options): string + { + $composeInput = is_array($options['compose_input'] ?? null) ? $options['compose_input'] : []; + $composeRole = strtolower(trim((string)($options['compose_role'] ?? $composeInput['role'] ?? ''))); + if (in_array($composeRole, ['primary', 'replica'], true)) { + return $composeRole; + } + + return $this->toBool($options['isolated_stack'] ?? $options['skip_replication_provisioning'] ?? false, false) + ? 'primary' + : 'replica'; + } + + private function targetSkipsReplicationProvisioning(array $target): bool + { + $options = self::jsonDecode($target['options_json'] ?? null); + + return $this->toBool($options['skip_replication_provisioning'] ?? $options['isolated_stack'] ?? false, false); + } + + private function attachTargetToReplicationHost(string $kind, int $hostId, int $targetId, int $instanceId): void + { + $host = $this->replicationHost($hostId, true); + $options = self::jsonDecode($host['options_json'] ?? null); + $options['deployment_provider'] = 'coolify'; + $options['coolify_instance_id'] = $instanceId; + $options['coolify_target_id'] = $targetId; + $this->execute( + 'UPDATE replication_hosts SET options_json = ? WHERE id = ? AND kind = ?', + 'sis', + [self::jsonEncode($options), $hostId, $kind] + ); + } + + private function blockedTargetOperation(array $target, array $host, string $operation, ?int $actorUserId): array + { + $context = [ + 'operation' => $operation, + 'reason' => 'active_primary_guard', + 'message' => 'Coolify will not mutate the active primary. Promote a healthy replica first.', + ]; + $this->execute( + "UPDATE coolify_targets SET availability_state = 'destructive_action_required', last_reconcile_status = ?, last_reconcile_json = ?, last_reconciled_at = NOW() WHERE id = ?", + 'ssi', + ['blocked', self::jsonEncode($context), (int)$target['id']] + ); + $this->audit((int)$target['id'], (int)$target['instance_id'], (int)$target['replication_host_id'], 'target_operation_blocked', $actorUserId, 'warning', $context); + + return [ + 'ok' => false, + 'status' => 'destructive_action_required', + 'message' => $context['message'], + 'target' => $this->publicTarget($this->getTarget((int)$target['id'])), + 'host' => [ + 'id' => (int)($host['id'] ?? 0), + 'role' => (string)($host['role'] ?? ''), + 'status' => (string)($host['status'] ?? ''), + ], + ]; + } + + private function availabilitySummary(): array + { + $summary = []; + foreach (self::KINDS as $kind) { + $targets = $this->listTargets($kind); + $states = array_map(static fn(array $target): string => (string)($target['availability_state'] ?? 'degraded'), $targets); + $summary[$kind] = [ + 'status' => in_array('protected', $states, true) ? 'protected' : ($targets === [] ? 'not_configured' : 'degraded'), + 'targets' => count($targets), + 'protected' => count(array_filter($states, static fn(string $state): bool => $state === 'protected' || $state === 'failover_ready')), + 'blocked' => count(array_filter($states, static fn(string $state): bool => str_contains($state, 'blocked') || $state === 'destructive_action_required')), + ]; + } + return $summary; + } + + private function loadBalancerConfig(): array + { + $mode = $this->coolifyConfigValue('lb_automation_mode', 'report_only'); + $mode = in_array($mode, ['report_only', 'enforce'], true) ? $mode : 'report_only'; + $token = $this->hetznerCloudToken(); + $tokenSource = trim((string)(getenv('HETZNER_CLOUD_API_TOKEN') ?: '')) !== '' ? 'env' : 'config'; + + return [ + 'automation_enabled' => $this->coolifyConfigBool('lb_automation_enabled', false), + 'automation_mode' => $mode, + 'load_balancer_id' => $this->coolifyConfigValue('hetzner_load_balancer_id', ''), + 'public_gateway_host' => $this->coolifyConfigValue('public_gateway_host', self::DEFAULT_PUBLIC_GATEWAY_HOST), + 'token' => $token, + 'token_set' => trim($token) !== '', + 'token_source' => trim($token) !== '' ? $tokenSource : null, + 'required_services' => self::REQUIRED_LOAD_BALANCER_SERVICES, + ]; + } + + private function publicLoadBalancerConfig(array $config): array + { + unset($config['token']); + return $config; + } + + private function coolifyConfigValue(string $variable, string $default = ''): string + { + $row = $this->selectOne( + "SELECT value FROM module_config WHERE module = 'Coolify' AND variable = ? LIMIT 1", + 's', + [$variable] + ); + $value = trim((string)($row['value'] ?? '')); + return $value !== '' ? $value : $default; + } + + private function coolifyConfigBool(string $variable, bool $default = false): bool + { + $row = $this->selectOne( + "SELECT value FROM module_config WHERE module = 'Coolify' AND variable = ? LIMIT 1", + 's', + [$variable] + ); + if ($row === null) { + return $default; + } + return $this->toBool($row['value'] ?? null, $default); + } + + private function hetznerCloudToken(): string + { + $envToken = trim((string)(getenv('HETZNER_CLOUD_API_TOKEN') ?: '')); + if ($envToken !== '') { + return $envToken; + } + + $stored = $this->coolifyConfigValue('hetzner_cloud_api_token', ''); + if ($stored === '') { + return ''; + } + + return replication_secret_box::decrypt($stored); + } + + private function hetznerClient(string $token): object + { + if ($this->hetznerClientFactory !== null) { + $client = call_user_func($this->hetznerClientFactory, $token); + foreach (['getLoadBalancer', 'addIpTarget', 'removeIpTarget', 'addService'] as $method) { + if (!is_object($client) || !method_exists($client, $method)) { + throw new RuntimeException('Hetzner client factory returned an invalid client.'); + } + } + return $client; + } + + return new hetzner_cloud_client($token); + } + + private function planLoadBalancerReconcile(array $loadBalancer, array $gateways): array + { + $actualTargetIps = self::loadBalancerIpTargets($loadBalancer); + $actualServices = self::loadBalancerServices($loadBalancer); + $enabledIps = []; + $actions = []; + $missingTargets = []; + $disabledPresentTargets = []; + $missingServices = []; + + foreach ($gateways as $gateway) { + $targetIp = trim((string)($gateway['target_ip'] ?? '')); + if ($targetIp === '') { + continue; + } + + if (empty($gateway['deleted_at']) && !empty($gateway['enabled'])) { + $enabledIps[] = $targetIp; + if (!in_array($targetIp, $actualTargetIps, true)) { + $missingTargets[] = $targetIp; + $actions[] = [ + 'type' => 'add_target', + 'target_ip' => $targetIp, + 'hostname' => $gateway['hostname'] ?? null, + ]; + } + continue; + } + + if (in_array($targetIp, $actualTargetIps, true)) { + $disabledPresentTargets[] = $targetIp; + $actions[] = [ + 'type' => 'remove_target', + 'target_ip' => $targetIp, + 'hostname' => $gateway['hostname'] ?? null, + ]; + } + } + + foreach (self::REQUIRED_LOAD_BALANCER_SERVICES as $requiredService) { + $actualService = self::matchingLoadBalancerService($actualServices, $requiredService); + if ($actualService === null) { + $missingServices[] = $requiredService; + $actions[] = array_replace(['type' => 'add_service'], $requiredService); + continue; + } + + if (!self::loadBalancerServiceHealthCheckMatches($actualService, $requiredService)) { + $actions[] = array_replace([ + 'type' => 'update_service', + 'reason' => 'health_check_drift', + 'actual_health_check' => $actualService['health_check'] ?? null, + ], $requiredService); + } + } + + $actions = $this->guardLastLoadBalancerTarget($actions, $actualTargetIps); + + return [ + 'has_drift' => $actions !== [], + 'actions' => array_values($actions), + 'missing_targets' => array_values($missingTargets), + 'disabled_present_targets' => array_values($disabledPresentTargets), + 'missing_services' => array_values($missingServices), + 'actual_target_ips' => $actualTargetIps, + 'expected_target_ips' => array_values(array_unique($enabledIps)), + 'actual_services' => $actualServices, + 'required_services' => self::REQUIRED_LOAD_BALANCER_SERVICES, + ]; + } + + private function guardLastLoadBalancerTarget(array $actions, array $actualTargetIps): array + { + $remainingTargets = count($actualTargetIps); + $guarded = []; + + foreach ($actions as $action) { + if (($action['type'] ?? '') !== 'remove_target') { + $guarded[] = $action; + continue; + } + + if ($remainingTargets <= 1) { + $guarded[] = array_replace($action, [ + 'type' => 'skip_remove_target', + 'reason' => 'last_reachable_target_guard', + ]); + continue; + } + + $remainingTargets--; + $guarded[] = $action; + } + + return $guarded; + } + + private function syncGatewayLoadBalancerStates(array $gateways, array $actualTargetIps): void + { + foreach ($gateways as $gateway) { + $targetIp = trim((string)($gateway['target_ip'] ?? '')); + if ($targetIp === '') { + continue; + } + $enabled = !empty($gateway['enabled']); + $present = in_array($targetIp, $actualTargetIps, true); + $state = match (true) { + $enabled && $present => 'in_lb', + $enabled && !$present => 'missing', + !$enabled && $present => 'disabled_present', + default => 'disabled_absent', + }; + $this->execute( + 'UPDATE coolify_instance_gateways SET lb_state = ?, last_reconciled_at = NOW() WHERE id = ?', + 'si', + [$state, (int)$gateway['id']] + ); + } + } + + private static function loadBalancerIpTargets(array $loadBalancer): array + { + $ips = []; + foreach (($loadBalancer['targets'] ?? []) as $target) { + if (!is_array($target)) { + continue; + } + $type = strtolower((string)($target['type'] ?? '')); + $ip = ''; + if ($type === 'ip') { + $ipPayload = is_array($target['ip'] ?? null) ? $target['ip'] : []; + $ip = (string)($ipPayload['ip'] ?? ''); + } elseif (isset($target['server']['public_net']['ipv4']['ip'])) { + $ip = (string)$target['server']['public_net']['ipv4']['ip']; + } + $ip = trim($ip); + if ($ip !== '') { + $ips[] = $ip; + } + } + + return array_values(array_unique($ips)); + } + + private static function loadBalancerServices(array $loadBalancer): array + { + $services = []; + foreach (($loadBalancer['services'] ?? []) as $service) { + if (!is_array($service)) { + continue; + } + $services[] = [ + 'protocol' => strtolower((string)($service['protocol'] ?? '')), + 'listen_port' => (int)($service['listen_port'] ?? 0), + 'destination_port' => (int)($service['destination_port'] ?? 0), + 'proxyprotocol' => (bool)($service['proxyprotocol'] ?? false), + 'health_check' => is_array($service['health_check'] ?? null) ? self::normalizeLoadBalancerHealthCheck($service['health_check']) : null, + ]; + } + return $services; + } + + private static function matchingLoadBalancerService(array $services, array $required): ?array + { + foreach ($services as $service) { + if ((string)$service['protocol'] === (string)$required['protocol'] + && (int)$service['listen_port'] === (int)$required['listen_port'] + && (int)$service['destination_port'] === (int)$required['destination_port'] + && empty($service['proxyprotocol'])) { + return $service; + } + } + + return null; + } + + private static function loadBalancerServiceHealthCheckMatches(array $actual, array $required): bool + { + $requiredHealthCheck = is_array($required['health_check'] ?? null) + ? self::normalizeLoadBalancerHealthCheck($required['health_check']) + : null; + if ($requiredHealthCheck === null) { + return true; + } + + $actualHealthCheck = is_array($actual['health_check'] ?? null) + ? self::normalizeLoadBalancerHealthCheck($actual['health_check']) + : null; + + return $actualHealthCheck === $requiredHealthCheck; + } + + private static function normalizeLoadBalancerHealthCheck(array $healthCheck): array + { + $normalized = [ + 'protocol' => strtolower((string)($healthCheck['protocol'] ?? '')), + 'port' => (int)($healthCheck['port'] ?? 0), + 'interval' => (int)($healthCheck['interval'] ?? 0), + 'timeout' => (int)($healthCheck['timeout'] ?? 0), + 'retries' => (int)($healthCheck['retries'] ?? 0), + ]; + + if (is_array($healthCheck['http'] ?? null)) { + $http = $healthCheck['http']; + $normalized['http'] = [ + 'domain' => (string)($http['domain'] ?? ''), + 'path' => (string)($http['path'] ?? ''), + 'response' => (string)($http['response'] ?? ''), + 'status_codes' => array_values(array_map('strval', is_array($http['status_codes'] ?? null) ? $http['status_codes'] : [])), + 'tls' => (bool)($http['tls'] ?? false), + ]; + } + + return $normalized; + } + + private function publicLoadBalancer(array $loadBalancer): array + { + return [ + 'id' => isset($loadBalancer['id']) ? (int)$loadBalancer['id'] : null, + 'name' => (string)($loadBalancer['name'] ?? ''), + 'ipv4' => $loadBalancer['public_net']['ipv4']['ip'] ?? null, + 'ipv6' => $loadBalancer['public_net']['ipv6']['ip'] ?? null, + 'location' => $loadBalancer['location']['name'] ?? null, + 'algorithm' => $loadBalancer['algorithm']['type'] ?? null, + 'targets' => self::loadBalancerIpTargets($loadBalancer), + 'services' => self::loadBalancerServices($loadBalancer), + ]; + } + + private function publicGateway(array $gateway): array + { + return [ + 'id' => (int)$gateway['id'], + 'instance_id' => isset($gateway['instance_id']) ? (int)$gateway['instance_id'] : null, + 'hostname' => (string)$gateway['hostname'], + 'target_ip' => (string)$gateway['target_ip'], + 'enabled' => (bool)$gateway['enabled'], + 'priority' => (int)$gateway['priority'], + 'health_state' => (string)($gateway['health_state'] ?? 'unknown'), + 'lb_state' => (string)($gateway['lb_state'] ?? 'unknown'), + 'last_probe' => self::jsonDecode($gateway['last_probe_json'] ?? null), + 'last_probed_at' => $gateway['last_probed_at'] ?? null, + 'last_reconciled_at' => $gateway['last_reconciled_at'] ?? null, + 'deleted_at' => $gateway['deleted_at'] ?? null, + 'created_at' => $gateway['created_at'] ?? null, + 'updated_at' => $gateway['updated_at'] ?? null, + ]; + } + + private function getGateway(int $id): array + { + $gateway = $this->selectOne( + 'SELECT * FROM coolify_instance_gateways WHERE id = ? AND deleted_at IS NULL LIMIT 1', + 'i', + [$id] + ); + if ($gateway === null) { + throw new RuntimeException('Coolify gateway target was not found.'); + } + return $gateway; + } + + private function probeGatewayTarget(string $targetIp, string $publicHost): array + { + return $this->probeGatewayEndpoint($publicHost, $targetIp); + } + + private function probeGatewayPublicHost(string $publicHost): array + { + return $this->probeGatewayEndpoint($publicHost, null); + } + + private function probeGatewayEndpoint(string $publicHost, ?string $targetIp): array + { + $startedAt = microtime(true); + $path = $this->gatewayProbePath($publicHost); + $url = 'https://' . $publicHost . $path; + $curl = curl_init($url); + if ($curl === false) { + throw new RuntimeException('Could not initialize gateway probe.'); + } + + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 3); + curl_setopt($curl, CURLOPT_TIMEOUT, 5); + curl_setopt($curl, CURLOPT_NOSIGNAL, true); + curl_setopt($curl, CURLOPT_HTTPHEADER, ['Accept: application/json']); + if ($targetIp !== null && $targetIp !== '') { + curl_setopt($curl, CURLOPT_RESOLVE, [$publicHost . ':443:' . $targetIp]); + } + curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 2); + curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, true); + if (defined('CURLOPT_CERTINFO')) { + curl_setopt($curl, CURLOPT_CERTINFO, true); + } + + $raw = curl_exec($curl); + $error = curl_error($curl); + $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + $sslVerifyResult = (int)curl_getinfo($curl, CURLINFO_SSL_VERIFYRESULT); + $certificateInfo = defined('CURLINFO_CERTINFO') + ? curl_getinfo($curl, CURLINFO_CERTINFO) + : []; + $certificate = self::gatewayProbeCertificate($certificateInfo); + curl_close($curl); + + $trustedCertificate = $sslVerifyResult === 0; + $letsencryptCertificate = (bool)($certificate['is_letsencrypt'] ?? false); + $ping = self::gatewayProbePingContract($raw); + $probeError = $raw === false ? $error : null; + if ($probeError === null && !$trustedCertificate) { + $probeError = 'Gateway TLS certificate verification failed.'; + } + if ($probeError === null && !$letsencryptCertificate) { + $probeError = "Gateway TLS certificate was not issued by Let's Encrypt."; + } + if ($probeError === null && !($ping['ok'] ?? false)) { + $probeError = 'Gateway ping response did not match the expected API contract.'; + } + + return [ + 'ok' => $raw !== false && $status >= 200 && $status < 300 && $trustedCertificate && $letsencryptCertificate && ($ping['ok'] ?? false), + 'status_code' => $status ?: null, + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'host' => $publicHost, + 'target_ip' => $targetIp, + 'path' => $path, + 'error' => $probeError, + 'ping' => $ping, + 'tls' => [ + 'verified' => $trustedCertificate, + 'ssl_verify_result' => $sslVerifyResult, + 'is_letsencrypt' => $letsencryptCertificate, + 'certificate' => $certificate, + ], + 'checked_at' => date('c'), + ]; + } + + private function gatewayProbePath(string $publicHost): string + { + $configured = self::normalizeGatewayProbePath($this->coolifyConfigValue('public_gateway_probe_path', '')); + if ($configured !== '') { + return $configured; + } + + foreach ($this->loadBalancerReleaseApiTargets() as $target) { + $targetUrl = self::gatewayRouteTargetPublicUrl($publicHost, $target); + $path = self::normalizeGatewayProbePath((string)(parse_url($targetUrl, PHP_URL_PATH) ?: '')); + if ($path !== '') { + return rtrim($path, '/') . '/ping'; + } + } + + return '/ping'; + } + + private static function normalizeGatewayProbePath(string $path): string + { + $path = trim($path); + if ($path === '') { + return ''; + } + + if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { + $path = (string)(parse_url($path, PHP_URL_PATH) ?: ''); + } + $path = trim($path); + if ($path === '') { + return ''; + } + + $path = '/' . ltrim($path, '/'); + $path = preg_replace('#/+#', '/', $path) ?: '/'; + return rtrim($path, '/') ?: '/'; + } + + private static function gatewayProbePingContract(mixed $raw): array + { + if (!is_string($raw) || trim($raw) === '') { + return ['ok' => false, 'reason' => 'empty_response']; + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return ['ok' => false, 'reason' => 'invalid_json']; + } + + $data = is_array($decoded['data'] ?? null) ? $decoded['data'] : []; + $message = strtolower(trim((string)($data['message'] ?? ''))); + return [ + 'ok' => ($decoded['success'] ?? false) === true && $message === 'pong', + 'message' => $data['message'] ?? null, + ]; + } + + private static function gatewayProbeCertificate(mixed $certificateInfo): ?array + { + if (!is_array($certificateInfo) || !is_array($certificateInfo[0] ?? null)) { + return null; + } + + $leaf = $certificateInfo[0]; + $issuer = self::certificateInfoValue($leaf, ['Issuer', 'issuer']); + $subject = self::certificateInfoValue($leaf, ['Subject', 'subject']); + $startDate = self::certificateInfoValue($leaf, ['Start date', 'Start Date', 'start date', 'startDate']); + $expireDate = self::certificateInfoValue($leaf, ['Expire date', 'Expire Date', 'expire date', 'expireDate']); + $expiresAt = self::certificateTimestamp($expireDate); + + return [ + 'subject' => $subject, + 'issuer' => $issuer, + 'start_date' => $startDate, + 'expire_date' => $expireDate, + 'expires_at' => $expiresAt !== null ? date('c', $expiresAt) : null, + 'days_until_expiry' => $expiresAt !== null ? (int)floor(($expiresAt - time()) / 86400) : null, + 'is_letsencrypt' => stripos((string)$issuer, "Let's Encrypt") !== false, + ]; + } + + private static function certificateInfoValue(array $certificate, array $keys): ?string + { + foreach ($keys as $key) { + if (isset($certificate[$key]) && is_scalar($certificate[$key])) { + $value = trim((string)$certificate[$key]); + if ($value !== '') { + return $value; + } + } + } + + return null; + } + + private static function certificateTimestamp(?string $value): ?int + { + if ($value === null || trim($value) === '') { + return null; + } + + $timestamp = strtotime($value); + return $timestamp === false ? null : $timestamp; + } + + private function recordGatewayProbe(int $gatewayId, array $probe): void + { + if ($gatewayId <= 0) { + return; + } + + $state = ($probe['ok'] ?? false) === true ? 'ok' : 'down'; + $this->execute( + "UPDATE coolify_instance_gateways + SET health_state = ?, last_probe_json = ?, last_probed_at = NOW() + WHERE id = ?", + 'ssi', + [$state, self::jsonEncode($probe), $gatewayId] + ); + } + + private function availabilityStateForHost(array $host): string + { + $role = (string)($host['role'] ?? ''); + $status = self::jsonDecode($host['last_status_json'] ?? null); + $effectiveStatus = (string)($status['status'] ?? $host['status'] ?? 'unknown'); + $percent = round((float)($status['replication_percent'] ?? ($role === 'primary' ? 100 : 0)), 2); + $blockers = is_array($status['blockers'] ?? null) ? $status['blockers'] : []; + + if ($role === 'primary') { + return $this->hasHealthyReplica((string)$host['kind'], (int)$host['id']) ? 'protected' : 'degraded'; + } + if ($effectiveStatus === 'ok' && $percent >= 100.0 && $blockers === []) { + return 'failover_ready'; + } + if (in_array($effectiveStatus, ['down', 'removed'], true)) { + return 'degraded'; + } + return 'failover_blocked'; + } + + private function replicationHostIsReady(array $host): bool + { + $status = self::jsonDecode($host['last_status_json'] ?? null); + $effectiveStatus = (string)($status['status'] ?? $host['status'] ?? 'unknown'); + $percent = round((float)($status['replication_percent'] ?? (($host['role'] ?? '') === 'primary' ? 100 : 0)), 2); + $blockers = is_array($status['blockers'] ?? null) ? $status['blockers'] : []; + + return $effectiveStatus === 'ok' && $percent >= 100.0 && $blockers === []; + } + + private function hasHealthyReplica(string $kind, int $primaryId): bool + { + foreach ($this->selectRows( + "SELECT * FROM replication_hosts WHERE kind = ? AND role = 'replica' AND deleted_at IS NULL AND id <> ?", + 'si', + [$kind, $primaryId] + ) as $host) { + $status = self::jsonDecode($host['last_status_json'] ?? null); + if (($status['status'] ?? '') === 'ok' + && round((float)($status['replication_percent'] ?? 0), 2) >= 100.0 + && (is_array($status['blockers'] ?? null) ? $status['blockers'] : []) === []) { + return true; + } + } + return false; + } + + private function coolifyCollection(array $response): array + { + if (self::isListArray($response)) { + return array_values(array_filter($response, 'is_array')); + } + + foreach (['data', 'items', 'servers', 'projects', 'environments', 'resources'] as $key) { + if (!is_array($response[$key] ?? null)) { + continue; + } + + $collection = $response[$key]; + if (self::isListArray($collection)) { + return array_values(array_filter($collection, 'is_array')); + } + + return array_values(array_filter($collection, 'is_array')); + } + + return []; + } + + private function publicPlacementServer(array $server): array + { + $settings = is_array($server['settings'] ?? null) ? $server['settings'] : []; + $publicHost = self::publicServerHostFromCoolifyServer($server, null, false) + ?? self::resolvedPublicDnsServerHostFromCoolifyServer($server); + + return [ + 'id' => isset($server['id']) ? (int)$server['id'] : null, + 'uuid' => $this->placementString($server['uuid'] ?? ''), + 'name' => $this->placementString($server['name'] ?? $server['uuid'] ?? ''), + 'description' => $this->placementString($server['description'] ?? ''), + 'ip' => $this->placementString($server['ip'] ?? $server['public_ip'] ?? $server['address'] ?? ''), + 'public_host' => $publicHost, + 'user' => $this->placementString($server['user'] ?? ''), + 'port' => isset($server['port']) ? (int)$server['port'] : null, + 'proxy_type' => $this->placementString($server['proxy_type'] ?? ''), + 'swarm_cluster' => $this->placementString($server['swarm_cluster'] ?? ''), + 'is_reachable' => array_key_exists('is_reachable', $settings) ? (bool)$settings['is_reachable'] : null, + 'is_usable' => array_key_exists('is_usable', $settings) ? (bool)$settings['is_usable'] : null, + ]; + } + + private function publicPlacementProject(array $project): array + { + return [ + 'id' => isset($project['id']) ? (int)$project['id'] : null, + 'uuid' => $this->placementString($project['uuid'] ?? ''), + 'name' => $this->placementString($project['name'] ?? $project['uuid'] ?? ''), + 'description' => $this->placementString($project['description'] ?? ''), + ]; + } + + private function publicPlacementEnvironment(array $environment, array $project): array + { + return [ + 'id' => isset($environment['id']) ? (int)$environment['id'] : null, + 'uuid' => $this->placementString($environment['uuid'] ?? ''), + 'name' => $this->placementString($environment['name'] ?? $environment['uuid'] ?? ''), + 'description' => $this->placementString($environment['description'] ?? ''), + 'project_id' => isset($environment['project_id']) ? (int)$environment['project_id'] : null, + 'project_uuid' => $this->placementString($project['uuid'] ?? ''), + 'project_name' => $this->placementString($project['name'] ?? ''), + ]; + } + + private function placementString(mixed $value): string + { + return trim((string)($value ?? '')); + } + + private function publicInstance(array $instance): array + { + return [ + 'id' => (int)$instance['id'], + 'label' => (string)$instance['label'], + 'base_url' => (string)$instance['base_url'], + 'api_token_set' => trim((string)($instance['api_token_secret'] ?? '')) !== '', + 'default_project_uuid' => $instance['default_project_uuid'] ?? null, + 'default_environment_uuid' => $instance['default_environment_uuid'] ?? null, + 'default_environment_name' => $instance['default_environment_name'] ?? null, + 'default_server_uuid' => $instance['default_server_uuid'] ?? null, + 'default_destination_uuid' => $instance['default_destination_uuid'] ?? null, + 'status' => (string)($instance['status'] ?? 'unknown'), + 'last_checked_at' => $instance['last_checked_at'] ?? null, + 'last_error' => $instance['last_error'] ?? null, + 'created_at' => $instance['created_at'] ?? null, + 'updated_at' => $instance['updated_at'] ?? null, + ]; + } + + private function publicTarget(array $target): array + { + $replication = [ + 'host_id' => isset($target['replication_host_id']) ? (int)$target['replication_host_id'] : null, + 'label' => $target['replication_label'] ?? null, + 'host' => $target['replication_host'] ?? null, + 'port' => isset($target['replication_port']) ? (int)$target['replication_port'] : null, + 'role' => $target['replication_role'] ?? null, + 'status' => $target['replication_status'] ?? null, + 'last_status' => self::jsonDecode($target['replication_last_status_json'] ?? null), + 'last_checked_at' => $target['replication_last_checked_at'] ?? null, + ]; + + return [ + 'id' => (int)$target['id'], + 'instance_id' => (int)$target['instance_id'], + 'instance_label' => (string)($target['instance_label'] ?? ''), + 'kind' => (string)$target['kind'], + 'label' => (string)$target['label'], + 'role' => (string)$target['role'], + 'server_uuid' => $target['server_uuid'] ?? null, + 'project_uuid' => $target['project_uuid'] ?? null, + 'environment_uuid' => $target['environment_uuid'] ?? null, + 'environment_name' => $target['environment_name'] ?? null, + 'destination_uuid' => $target['destination_uuid'] ?? null, + 'resource_uuid' => $target['resource_uuid'] ?? null, + 'resource_type' => (string)($target['resource_type'] ?? self::RESOURCE_TYPE_SERVICE), + 'resource_name' => $target['resource_name'] ?? null, + 'deployment_status' => (string)($target['deployment_status'] ?? 'unknown'), + 'availability_state' => (string)($target['availability_state'] ?? 'degraded'), + 'last_reconcile_status' => $target['last_reconcile_status'] ?? null, + 'last_reconcile' => self::jsonDecode($target['last_reconcile_json'] ?? null), + 'last_reconciled_at' => $target['last_reconciled_at'] ?? null, + 'replication' => $replication, + 'created_at' => $target['created_at'] ?? null, + 'updated_at' => $target['updated_at'] ?? null, + ]; + } + + private function clientForInstance(array $instance): coolify_api_client + { + $token = replication_secret_box::decrypt($instance['api_token_secret'] ?? ''); + if ($this->clientFactory !== null) { + $client = call_user_func($this->clientFactory, $instance, $token); + if (!$client instanceof coolify_api_client) { + throw new RuntimeException('Coolify client factory returned an invalid client.'); + } + return $client; + } + return new coolify_api_client((string)$instance['base_url'], $token); + } + + private function getInstance(int $id): array + { + $instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL LIMIT 1', 'i', [$id]); + if ($instance === null) { + throw new RuntimeException('Coolify instance was not found.'); + } + return $instance; + } + + private function getTarget(int $id): array + { + $target = $this->selectOne( + "SELECT t.*, i.label AS instance_label, i.base_url AS instance_base_url, h.label AS replication_label, + h.host AS replication_host, h.port AS replication_port, h.role AS replication_role, + h.status AS replication_status, h.last_status_json AS replication_last_status_json, + h.last_checked_at AS replication_last_checked_at + FROM coolify_targets t + INNER JOIN coolify_instances i ON i.id = t.instance_id + LEFT JOIN replication_hosts h ON h.id = t.replication_host_id + WHERE t.id = ? AND t.deleted_at IS NULL LIMIT 1", + 'i', + [$id] + ); + if ($target === null) { + throw new RuntimeException('Coolify target was not found.'); + } + return $target; + } + + private function replicationHost(int $id, bool $includeDeleted = false): array + { + $sql = 'SELECT * FROM replication_hosts WHERE id = ?'; + if (!$includeDeleted) { + $sql .= ' AND deleted_at IS NULL'; + } + $host = $this->selectOne($sql . ' LIMIT 1', 'i', [$id]); + if ($host === null) { + throw new RuntimeException('Linked replication host was not found.'); + } + return $host; + } + + private 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'] ?? ''), + 'replication_username' => (string)($host['replication_username'] ?? ''), + 'replication_password' => replication_secret_box::decrypt($host['replication_password_secret'] ?? ''), + ]; + } + + private function primaryAddress(string $kind): array + { + $primary = $this->selectOne( + "SELECT * FROM replication_hosts WHERE kind = ? AND role = 'primary' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1", + 's', + [$kind] + ); + if ($primary === null) { + return match ($kind) { + 'database' => ['', 3306], + 'redis' => ['redis-primary', 6379], + default => ['http://minio-primary:9000', 9000], + }; + } + + if ($kind === 'minio') { + $options = self::jsonDecode($primary['options_json'] ?? null); + $endpoint = (string)($options['endpoint'] ?? (($options['scheme'] ?? 'http') . '://' . $primary['host'] . ':' . $primary['port'])); + return [$endpoint, (int)$primary['port']]; + } + + return [(string)$primary['host'], (int)$primary['port']]; + } + + private function defaultInstanceId(): int + { + $instance = $this->selectOne('SELECT id FROM coolify_instances WHERE deleted_at IS NULL ORDER BY id LIMIT 1'); + if ($instance === null) { + throw new RuntimeException('No Coolify instance is configured.'); + } + return (int)$instance['id']; + } + + private function applyCoolifyDeploymentDefaults(array $input, array $instance): array + { + $serverUuid = $this->targetMapping($input, $instance, 'server_uuid'); + if ($serverUuid === null) { + return $input; + } + + $serverHost = $this->resolveCoolifyServerHost( + $instance, + $serverUuid, + (int)($input['host_port'] ?? $input['port'] ?? 0), + 0 + ); + if ($serverHost !== null) { + $input['host'] = $serverHost; + } + + return $input; + } + + private function applyCoolifyPortDefaults(string $kind, array $input, array $instance): array + { + $serverUuid = $this->targetMapping($input, $instance, 'server_uuid'); + if ($serverUuid === null) { + return $input; + } + + $port = (int)($input['host_port'] ?? $input['port'] ?? match ($kind) { + 'database' => 3307, + 'redis' => 6380, + default => 9010, + }); + $consolePort = $kind === 'minio' ? (int)($input['console_port'] ?? ($port + 1)) : null; + [$nextPort, $nextConsolePort] = $this->nextAvailablePublicPorts( + $kind, + $port, + $consolePort, + $this->usedPublicPortsForCoolifyServer($serverUuid, 0) + ); + + $input['host_port'] = $nextPort; + $input['port'] = $nextPort; + if ($kind === 'minio' && $nextConsolePort !== null) { + $input['console_port'] = $nextConsolePort; + } + + return $input; + } + + private function resolveCoolifyServerHost(array $instance, string $serverUuid, ?int $port = null, int $excludeHostId = 0): ?string + { + try { + foreach ($this->coolifyCollection($this->clientForInstance($instance)->listServers()) as $server) { + if ($this->placementString($server['uuid'] ?? '') !== $serverUuid) { + continue; + } + + $publicHost = self::publicServerHostFromCoolifyServer($server, $port, false); + if ($publicHost !== null) { + return $publicHost; + } + + $knownHost = $this->knownPublicHostForCoolifyServer($serverUuid, $port, $excludeHostId); + if ($knownHost !== null) { + return $knownHost; + } + + return self::resolvedPublicDnsServerHostFromCoolifyServer($server); + } + } catch (Throwable) { + } + + return $this->knownPublicHostForCoolifyServer($serverUuid, $port, $excludeHostId); + } + + private function syncReplicationHostEndpointForTarget(array $target, array $host, array $instance): array + { + $serverUuid = trim((string)($target['server_uuid'] ?? '')); + $hostId = (int)($host['id'] ?? 0); + if ($serverUuid === '' || $hostId <= 0) { + return $host; + } + + $port = (int)($host['port'] ?? 0); + $publicHost = $this->resolveCoolifyServerHost($instance, $serverUuid, $port, $hostId); + if ($publicHost === null || $publicHost === trim((string)($host['host'] ?? ''))) { + return $host; + } + + $options = self::jsonDecode($host['options_json'] ?? null); + if ((string)($target['kind'] ?? '') === 'minio') { + $scheme = strtolower(trim((string)($options['scheme'] ?? 'http'))) ?: 'http'; + $options['endpoint'] = $scheme . '://' . $publicHost . ':' . $port; + } + + $optionsJson = self::jsonEncode($options); + $this->execute( + 'UPDATE replication_hosts SET host = ?, options_json = ? WHERE id = ?', + 'ssi', + [$publicHost, $optionsJson, $hostId] + ); + + $host['host'] = $publicHost; + $host['options_json'] = $optionsJson; + return $host; + } + + private function syncReplicationHostPortsForTarget(array $target, array $host): array + { + $serverUuid = trim((string)($target['server_uuid'] ?? '')); + $hostId = (int)($host['id'] ?? 0); + $kind = (string)($target['kind'] ?? ''); + if ($serverUuid === '' || $hostId <= 0 || (string)($host['role'] ?? '') === 'primary') { + return $host; + } + + $options = self::jsonDecode($host['options_json'] ?? null); + $port = (int)($host['port'] ?? 0); + $consolePort = $kind === 'minio' ? (int)($options['console_port'] ?? ($port + 1)) : null; + if ($port <= 0) { + return $host; + } + + $usedPorts = $this->usedPublicPortsForCoolifyServer($serverUuid, $hostId); + [$nextPort, $nextConsolePort] = $this->nextAvailablePublicPorts($kind, $port, $consolePort, $usedPorts); + if ($nextPort === $port && ($kind !== 'minio' || $nextConsolePort === $consolePort)) { + return $host; + } + + if ($kind === 'minio') { + $scheme = strtolower(trim((string)($options['scheme'] ?? 'http'))) ?: 'http'; + $options['console_port'] = $nextConsolePort; + $options['endpoint'] = $scheme . '://' . (string)$host['host'] . ':' . $nextPort; + } + + $optionsJson = self::jsonEncode($options); + $this->execute( + 'UPDATE replication_hosts SET port = ?, options_json = ? WHERE id = ?', + 'isi', + [$nextPort, $optionsJson, $hostId] + ); + + $host['port'] = $nextPort; + $host['options_json'] = $optionsJson; + return $host; + } + + private function usedPublicPortsForCoolifyServer(string $serverUuid, int $excludeHostId): array + { + $rows = $this->selectRows( + "SELECT h.port, h.options_json, t.last_reconcile_json + FROM coolify_targets t + INNER JOIN replication_hosts h ON h.id = t.replication_host_id + WHERE t.server_uuid = ? AND h.id <> ? AND t.deleted_at IS NULL AND h.deleted_at IS NULL + LIMIT 100", + 'si', + [$serverUuid, $excludeHostId] + ); + + $ports = []; + foreach ($rows as $row) { + $port = (int)($row['port'] ?? 0); + if ($port > 0) { + $ports[$port] = true; + } + $options = self::jsonDecode($row['options_json'] ?? null); + $consolePort = (int)($options['console_port'] ?? 0); + if ($consolePort > 0) { + $ports[$consolePort] = true; + } + foreach (self::coolifyApplicationPortsFromContext(self::jsonDecode($row['last_reconcile_json'] ?? null)) as $applicationPort) { + $ports[$applicationPort] = true; + } + } + + return array_keys($ports); + } + + private function nextAvailablePublicPorts(string $kind, int $port, ?int $consolePort, array $usedPorts): array + { + $used = array_fill_keys(array_map('intval', $usedPorts), true); + if ($kind !== 'minio') { + while (isset($used[$port]) && $port < 65535) { + $port++; + } + return [$port, null]; + } + + $consolePort = $consolePort !== null && $consolePort > 0 ? $consolePort : ($port + 1); + while ((isset($used[$port]) || isset($used[$consolePort])) && $consolePort < 65535) { + $port += 2; + $consolePort = $port + 1; + } + + return [$port, $consolePort]; + } + + private static function coolifyApplicationPortsFromContext(array $context): array + { + $ports = []; + $applications = $context['coolify']['applications'] ?? []; + if (!is_array($applications)) { + return []; + } + + foreach ($applications as $application) { + if (!is_array($application)) { + continue; + } + foreach (preg_split('/\s*,\s*/', (string)($application['ports'] ?? '')) ?: [] as $mapping) { + if (preg_match('/^(\d+)\s*:/', trim($mapping), $matches) === 1) { + $ports[] = (int)$matches[1]; + } + } + } + + return array_values(array_unique(array_filter($ports))); + } + + private function knownPublicHostForCoolifyServer(string $serverUuid, ?int $port = null, int $excludeHostId = 0): ?string + { + if ($serverUuid === '') { + return null; + } + + $where = 't.server_uuid = ? AND t.deleted_at IS NULL AND h.deleted_at IS NULL'; + $types = 's'; + $params = [$serverUuid]; + if ($excludeHostId > 0) { + $where .= ' AND h.id <> ?'; + $types .= 'i'; + $params[] = $excludeHostId; + } + + $rows = $this->selectRows( + "SELECT h.host, h.status, h.last_status_json + FROM coolify_targets t + INNER JOIN replication_hosts h ON h.id = t.replication_host_id + WHERE $where + ORDER BY (h.status = 'ok') DESC, h.last_checked_at DESC, h.updated_at DESC, h.id DESC + LIMIT 20", + $types, + $params + ); + + $fallback = null; + foreach ($rows as $row) { + $host = self::publicServerHostCandidate($row['host'] ?? null); + if ($host === null) { + continue; + } + $lastStatus = self::jsonDecode($row['last_status_json'] ?? null); + $isHealthy = (string)($row['status'] ?? '') === 'ok' || (string)($lastStatus['status'] ?? '') === 'ok'; + if ($fallback === null && $isHealthy) { + $fallback = $host; + } + if ($port !== null && $port > 0 && self::tcpPortIsOpen($host, $port)) { + return $host; + } + } + + return $fallback; + } + + public static function publicServerHostFromCoolifyServer(array $server, ?int $port = null, bool $includeDisplayName = true): ?string + { + $candidates = []; + foreach ([ + 'public_host', + 'publicHost', + 'public_ip', + 'publicIp', + 'public_ipv4', + 'publicIpv4', + 'public_ipv6', + 'publicIpv6', + 'address', + 'hostname', + 'fqdn', + 'domain', + 'ip', + ] as $key) { + $host = self::publicServerHostCandidate($server[$key] ?? null); + if ($host !== null && !in_array($host, $candidates, true)) { + $candidates[] = $host; + } + } + + if ($includeDisplayName) { + $host = self::publicServerHostCandidate($server['name'] ?? null); + if ($host !== null && !in_array($host, $candidates, true)) { + $candidates[] = $host; + } + } + + if ($port !== null && $port > 0) { + foreach ($candidates as $host) { + if (self::tcpPortIsOpen($host, $port)) { + return $host; + } + } + } + + return $candidates[0] ?? null; + } + + public static function publicDnsServerNameFromCoolifyServer(array $server): ?string + { + $host = self::publicServerHostCandidate($server['name'] ?? null); + if ($host === null || !self::isPublicDnsName($host)) { + return null; + } + + return $host; + } + + private static function resolvedPublicDnsServerHostFromCoolifyServer(array $server): ?string + { + $host = self::publicDnsServerNameFromCoolifyServer($server); + if ($host === null) { + return null; + } + + foreach (@gethostbynamel($host) ?: [] as $address) { + $address = self::publicServerHostCandidate($address); + if ($address !== null) { + return $address; + } + } + + return $host; + } + + private static function isPublicDnsName(string $host): bool + { + $host = strtolower(trim($host, '.')); + return str_contains($host, '.') + && preg_match('/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/', $host) === 1 + && preg_match('/[a-z]/', $host) === 1 + && !str_contains($host, '..'); + } + + private static function tcpPortIsOpen(string $host, int $port): bool + { + if ($port <= 0 || $port > 65535) { + return false; + } + + $errno = 0; + $errstr = ''; + $socket = @fsockopen($host, $port, $errno, $errstr, 0.4); + if (is_resource($socket)) { + fclose($socket); + return true; + } + + return false; + } + + private static function publicServerHostCandidate(mixed $value): ?string + { + $host = trim((string)($value ?? '')); + if ($host === '') { + return null; + } + + if (str_contains($host, '://')) { + $parsed = parse_url($host, PHP_URL_HOST); + $host = is_string($parsed) ? $parsed : $host; + } + + $host = trim($host); + if (str_contains($host, '/')) { + $host = strtok($host, '/') ?: ''; + } + if (str_contains($host, ':') && substr_count($host, ':') === 1) { + $host = explode(':', $host, 2)[0]; + } + + $host = trim($host, " \t\n\r\0\x0B[]"); + if ($host === '' || preg_match('/\s/', $host) === 1 || self::isDockerLocalOrLoopbackHost($host)) { + return null; + } + + return $host; + } + + private static function publicIpFromHost(mixed $value): ?string + { + $host = self::publicServerHostCandidate($value); + if ($host === null) { + return null; + } + + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return $host; + } + + foreach (@gethostbynamel($host) ?: [] as $address) { + $address = self::publicServerHostCandidate($address); + if ($address !== null && filter_var($address, FILTER_VALIDATE_IP) !== false) { + return $address; + } + } + + return null; + } + + private static function firstPublicUrl(mixed $value): ?string + { + if (is_string($value)) { + foreach (preg_split('/[\s,]+/', trim($value)) ?: [] as $candidate) { + $candidate = trim($candidate); + if ($candidate !== '') { + return $candidate; + } + } + return null; + } + + if (!is_array($value)) { + return null; + } + + foreach (['url', 'fqdn', 'domain', 'domains'] as $key) { + if (array_key_exists($key, $value)) { + $candidate = self::firstPublicUrl($value[$key]); + if ($candidate !== null) { + return $candidate; + } + } + } + + foreach ($value as $entry) { + $candidate = self::firstPublicUrl($entry); + if ($candidate !== null) { + return $candidate; + } + } + + return null; + } + + private static function isDockerLocalOrLoopbackHost(string $host): bool + { + $normalized = strtolower(trim($host, '[]')); + if (in_array($normalized, [ + 'localhost', + 'host.docker.internal', + 'host.containers.internal', + 'docker.for.win.localhost', + 'docker.for.mac.localhost', + '0.0.0.0', + '::', + '::1', + '0:0:0:0:0:0:0:1', + ], true)) { + return true; + } + + return str_starts_with($normalized, '127.') + || str_starts_with($normalized, '169.254.') + || str_starts_with($normalized, 'fe80:'); + } + + private function targetMapping(array $input, array $instance, string $key): ?string + { + $defaultKey = 'default_' . $key; + return $this->nullableString($input[$key] ?? $instance[$defaultKey] ?? null); + } + + private function nullableString(mixed $value): ?string + { + $value = trim((string)($value ?? '')); + return $value === '' ? null : $value; + } + + private function normalizeBuckets(mixed $value): array + { + if (is_array($value)) { + return array_values(array_filter(array_map('strval', $value))); + } + return array_values(array_filter(array_map('trim', preg_split('/[,\s]+/', (string)$value) ?: []))); + } + + private static function resourceName(string $kind, string $name, int $hostId): string + { + $name = strtolower(trim($name)); + $name = preg_replace('/[^a-z0-9-]+/', '-', $name) ?: ''; + $name = trim($name, '-'); + if ($name === '') { + $name = 'truckwash-' . $kind . '-replica'; + } + return substr($name . '-' . $hostId, 0, 120); + } + + private function composeHash(array $template): string + { + return hash('sha256', (string)($template['compose'] ?? '') . "\n---env---\n" . (string)($template['env'] ?? '')); + } + + private function startOperation(?int $targetId, ?int $instanceId, string $operation, ?int $actorUserId): int + { + $this->execute( + "INSERT INTO coolify_operations (target_id, instance_id, operation, status, actor_user_id) + VALUES (?, ?, ?, 'running', ?)", + 'iisi', + [$targetId, $instanceId, $operation, $actorUserId] + ); + return $this->insertId(); + } + + private function finishOperation(int $operationId, string $status, ?string $message, array $errors): void + { + $this->execute( + "UPDATE coolify_operations SET status = ?, message = ?, error_message = ?, completed_at = NOW() WHERE id = ?", + 'sssi', + [$status, $message, implode("\n", $errors), $operationId] + ); + } + + private function markTargetFailure(int $targetId, string $status, string $message, array $context = []): void + { + $payload = array_replace($context, ['message' => $message, 'status' => $status]); + $this->execute( + "UPDATE coolify_targets + SET deployment_status = ?, availability_state = 'degraded', last_reconcile_status = ?, last_reconcile_json = ?, last_reconciled_at = NOW() + WHERE id = ?", + 'sssi', + [$status, $status, self::jsonEncode($payload), $targetId] + ); + } + + private function audit(?int $targetId, ?int $instanceId, ?int $hostId, string $action, ?int $actorUserId, string $severity, array $context): void + { + $this->execute( + "INSERT INTO coolify_audit_logs (target_id, instance_id, replication_host_id, action, actor_user_id, severity, context_json) + VALUES (?, ?, ?, ?, ?, ?, ?)", + 'iiisiss', + [$targetId, $instanceId, $hostId, $action, $actorUserId, $severity, self::jsonEncode($context)] + ); + } + + private function setModuleEnabled(bool $enabled): void + { + $value = $enabled ? 'true' : 'false'; + $row = $this->selectOne("SELECT value FROM module_config WHERE module = 'Coolify' AND variable = 'enabled' LIMIT 1"); + if ($row === null) { + $this->execute("INSERT INTO module_config (module, variable, value, type) VALUES ('Coolify', 'enabled', ?, 'bool')", 's', [$value]); + return; + } + $this->execute("UPDATE module_config SET value = ? WHERE module = 'Coolify' AND variable = 'enabled'", 's', [$value]); + } + + private function ensureFailoverEnabled(string $kind): void + { + $this->setModuleConfigValue('Failover', 'enabled', 'true', 'bool'); + $this->setModuleConfigValue('Failover', $kind . '_enabled', 'true', 'bool'); + } + + private function setModuleConfigValue(string $module, string $variable, string $value, string $type): void + { + $row = $this->selectOne( + 'SELECT value FROM module_config WHERE module = ? AND variable = ? LIMIT 1', + 'ss', + [$module, $variable] + ); + if ($row === null) { + $this->execute( + 'INSERT INTO module_config (module, variable, value, type) VALUES (?, ?, ?, ?)', + 'ssss', + [$module, $variable, $value, $type] + ); + return; + } + $this->execute( + 'UPDATE module_config SET value = ?, type = ? WHERE module = ? AND variable = ?', + 'ssss', + [$value, $type, $module, $variable] + ); + } + + private function tableExists(string $table): bool + { + $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table) ?? ''; + if ($table === '') { + return false; + } + + try { + return $this->selectOne( + 'SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ? LIMIT 1', + 's', + [$table] + ) !== null; + } catch (Throwable) { + return false; + } + } + + 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 Coolify 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 Coolify statement.'); + } + $stmt->bind_param($types, ...$params); + $stmt->execute(); + } + + private function insertId(): int + { + global $db; + return (int)$db->insert_id(); + } + + private function toBool(mixed $value, bool $default): bool + { + if (is_bool($value)) { + return $value; + } + if ($value === null) { + return $default; + } + $normalized = strtolower(trim((string)$value)); + if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { + return false; + } + return $default; + } + + private static function hostHasCoolifyMetadata(array $host): bool + { + $options = isset($host['options']) && is_array($host['options']) + ? $host['options'] + : self::jsonDecode($host['options_json'] ?? null); + + return (string)($options['deployment_provider'] ?? '') === 'coolify' + || isset($options['coolify_target_id']) + || isset($options['coolify_instance_id']); + } + + private static function redactCoolifyResponse(array $response): array + { + foreach (['token', 'api_token', 'password', 'secret', 'real_value'] as $key) { + if (array_key_exists($key, $response)) { + $response[$key] = '[redacted]'; + } + } + return $response; + } + + private static function jsonEncode(mixed $value): string + { + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Could not encode Coolify JSON payload.'); + } + return $json; + } + + private static function jsonDecode(mixed $value): array + { + if (!is_string($value) || trim($value) === '') { + return []; + } + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : []; + } + + private static function isListArray(array $value): bool + { + if ($value === []) { + return true; + } + + return array_keys($value) === range(0, count($value) - 1); + } +} diff --git a/services/nginx/app/classes/coolify_schema_bootstrap.php b/services/nginx/app/classes/coolify_schema_bootstrap.php new file mode 100644 index 00000000..1d349625 --- /dev/null +++ b/services/nginx/app/classes/coolify_schema_bootstrap.php @@ -0,0 +1,237 @@ +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 . ")" + ); + } +} diff --git a/services/nginx/app/classes/cors_policy.php b/services/nginx/app/classes/cors_policy.php new file mode 100644 index 00000000..592d39bc --- /dev/null +++ b/services/nginx/app/classes/cors_policy.php @@ -0,0 +1,188 @@ + + */ + public static function requiredAllowedOrigins(): array + { + return self::REQUIRED_ALLOWED_ORIGINS; + } + + /** + * @return array + */ + 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 + */ + 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,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 $headers + */ + public static function emitHeaders(array $headers): void + { + foreach ($headers as $name => $value) { + header($name . ': ' . $value, strtolower((string)$name) !== 'vary'); + } + } + + /** + * @return array + */ + private static function splitOrigins(string $corsConfig): array + { + return array_values(array_filter( + array_map('trim', explode(',', $corsConfig)), + static fn(string $origin): bool => $origin !== '' + )); + } +} diff --git a/services/nginx/app/classes/customer_name_cache_payload_builder.php b/services/nginx/app/classes/customer_name_cache_payload_builder.php index 5bd0dc38..510ec2cd 100644 --- a/services/nginx/app/classes/customer_name_cache_payload_builder.php +++ b/services/nginx/app/classes/customer_name_cache_payload_builder.php @@ -9,19 +9,86 @@ class customer_name_cache_payload_builder */ public static function build(mixed $cached_name, ?string $fallback_name): ?array { - if ( - is_object($cached_name) - && isset($cached_name->name) - && is_string($cached_name->name) - && trim($cached_name->name) !== '' - ) { - return ['name' => $cached_name->name]; + $cached_name = self::normalizePayload($cached_name); + $name = self::extractName($cached_name); + if ($name !== null) { + return ['name' => $name]; } - if ($fallback_name !== null && trim($fallback_name) !== '') { + $fallback_name = self::normalizeName($fallback_name); + if ($fallback_name !== null) { return ['name' => $fallback_name]; } return null; } + + private static function normalizePayload(mixed $payload): mixed + { + if (!is_string($payload)) { + return $payload; + } + + $trimmed = trim($payload); + if ($trimmed === '') { + return null; + } + + $decoded = json_decode($trimmed); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + + return $trimmed; + } + + private static function extractName(mixed $payload): ?string + { + if (is_string($payload)) { + return self::normalizeName($payload); + } + + if (!is_object($payload) && !is_array($payload)) { + return null; + } + + foreach (['name', 'customerName', 'customer_name', 'displayName', 'display_name'] as $key) { + $name = self::normalizeName(self::payloadValue($payload, $key)); + if ($name !== null) { + return $name; + } + } + + foreach (['customer', 'data', 'economic_customer'] as $key) { + $name = self::extractName(self::payloadValue($payload, $key)); + if ($name !== null) { + return $name; + } + } + + return null; + } + + private static function payloadValue(mixed $payload, string $key): mixed + { + if (is_object($payload) && property_exists($payload, $key)) { + return $payload->{$key}; + } + + if (is_array($payload) && array_key_exists($key, $payload)) { + return $payload[$key]; + } + + return null; + } + + private static function normalizeName(mixed $name): ?string + { + if (!is_string($name)) { + return null; + } + + $name = trim($name); + return $name === '' ? null : $name; + } } diff --git a/services/nginx/app/classes/db.php b/services/nginx/app/classes/db.php index c8a0c623..0a313420 100644 --- a/services/nginx/app/classes/db.php +++ b/services/nginx/app/classes/db.php @@ -82,7 +82,14 @@ class db public function close(): void { - $this->conn->close(); + if (!isset($this->conn)) { + return; + } + + try { + $this->conn->close(); + } catch (\Throwable) { + } } public function get(string $table, int $id) diff --git a/services/nginx/app/classes/department_daily_report_complaints_schema_bootstrap.php b/services/nginx/app/classes/department_daily_report_complaints_schema_bootstrap.php index 91b4edd0..716320c8 100644 --- a/services/nginx/app/classes/department_daily_report_complaints_schema_bootstrap.php +++ b/services/nginx/app/classes/department_daily_report_complaints_schema_bootstrap.php @@ -76,11 +76,13 @@ class department_daily_report_complaints_schema_bootstrap dimension INT NOT NULL DEFAULT 0, branding INT NOT NULL DEFAULT 0, visible TINYINT(1) NOT NULL DEFAULT 1, + archived TINYINT(1) NOT NULL DEFAULT 0, longitude DECIMAL(10,7) NOT NULL DEFAULT 0, latitude DECIMAL(10,7) NOT NULL DEFAULT 0, order_priority INT NOT NULL DEFAULT 0, created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_departments_archived (archived) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" ); diff --git a/services/nginx/app/classes/departments_schema_bootstrap.php b/services/nginx/app/classes/departments_schema_bootstrap.php new file mode 100644 index 00000000..c6e0eed6 --- /dev/null +++ b/services/nginx/app/classes/departments_schema_bootstrap.php @@ -0,0 +1,89 @@ +query( + "ALTER TABLE departments + ADD COLUMN archived TINYINT(1) NOT NULL DEFAULT 0 + AFTER visible" + ); + } + + if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) { + $db->query( + "ALTER TABLE departments + ADD INDEX " . self::ARCHIVED_INDEX . " (archived)" + ); + } + + self::$initialized = true; + } + + private static function tableExists(object $db, string $table): bool + { + $table = self::escapeIdentifier($table); + $result = $db->query("SHOW TABLES LIKE '{$table}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function columnExists(object $db, string $table, string $column): bool + { + $table = self::escapeIdentifier($table); + $column = self::escapeIdentifier($column); + $result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function indexExists(object $db, string $table, string $index): bool + { + $table = self::escapeIdentifier($table); + $index = self::escapeIdentifier($index); + $result = $db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function escapeIdentifier(string $value): string + { + return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value); + } +} diff --git a/services/nginx/app/classes/economic.php b/services/nginx/app/classes/economic.php index 314ad730..5a44b1e9 100644 --- a/services/nginx/app/classes/economic.php +++ b/services/nginx/app/classes/economic.php @@ -30,6 +30,7 @@ use interfaces\economic_i; class economic implements economic_i { public const DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE = 'Transactions for the configured draft customer cannot be exported to e-conomic.'; + public const DEFAULT_DISTRIBUTION_DEPARTMENT_ID = 1; /** * Configuration of the economic module @@ -133,6 +134,14 @@ class economic implements economic_i return $customer_number > 0 ? $customer_number : null; } + public function getDefaultDistributionDepartmentId(): int + { + $value = $this->config->default_department_id->getVariableValue(); + $department_id = (int)$value; + + return $department_id > 0 ? $department_id : self::DEFAULT_DISTRIBUTION_DEPARTMENT_ID; + } + public function isDraftCustomerNumber(?int $customer_number): bool { $configured_customer_number = $this->getTransactionDraftCustomerNumber(); @@ -156,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' => [ @@ -170,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; } /** diff --git a/services/nginx/app/classes/economic_transfer_queue.php b/services/nginx/app/classes/economic_transfer_queue.php index 4ccc476a..48392921 100644 --- a/services/nginx/app/classes/economic_transfer_queue.php +++ b/services/nginx/app/classes/economic_transfer_queue.php @@ -154,6 +154,132 @@ class economic_transfer_queue return max(0, (int)$row['total']); } + public function listMonitorJobsForUser(int $user_id, int $limit = 50, ?string $transfer_type = null): array + { + global $db; + + $user_id = max(0, $user_id); + $limit = max(1, min(100, $limit)); + try { + $normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== '' + ? $this->validateTransferType($transfer_type) + : null; + } catch (Exception) { + return []; + } + + $transfer_condition = ''; + if ($normalized_transfer_type !== null) { + $transfer_condition = "AND q.transfer_type = '" . $db->escape_string($normalized_transfer_type) . "'"; + } + + $sql = "SELECT q.* + FROM economic_transfer_queue_jobs q + LEFT JOIN economic_transfer_queue_job_dismissals d + ON d.queue_job_id = q.id + AND d.user_id = $user_id + AND d.dismissed_status = q.status + WHERE 1 = 1 + $transfer_condition + AND ( + q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "') + OR d.queue_job_id IS NULL + ) + ORDER BY + CASE WHEN q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "') THEN 0 ELSE 1 END, + q.id DESC + LIMIT $limit"; + $result = $db->query($sql); + if (!$result instanceof mysqli_result) { + return []; + } + + $jobs = []; + while ($row = $result->fetch_assoc()) { + $jobs[] = $this->normalizeJobRow($row); + } + return $jobs; + } + + /** + * @throws Exception + */ + public function dismissTerminalJobForUser(int $job_id, int $user_id): array + { + global $db; + + $job_id = max(0, $job_id); + $user_id = max(0, $user_id); + if ($job_id < 1 || $user_id < 1) { + throw new Exception('Queue job and user are required'); + } + + $job = $this->getJobById($job_id); + if ($job === null) { + throw new Exception('Queue job not found'); + } + + $status = strtoupper((string)($job['status'] ?? '')); + if (!in_array($status, [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) { + throw new Exception('Only completed or failed queue jobs can be dismissed'); + } + + $stmt = $db->prepare( + "INSERT INTO economic_transfer_queue_job_dismissals (queue_job_id, user_id, dismissed_status, dismissed_at) + VALUES (?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()" + ); + if (!$stmt) { + throw new Exception('Failed to prepare queue dismissal statement'); + } + + $stmt->bind_param('iis', $job_id, $user_id, $status); + if (!$stmt->execute()) { + $stmt->close(); + throw new Exception('Failed to dismiss queue job'); + } + $stmt->close(); + + return $job; + } + + public function dismissTerminalJobsForUser(int $user_id, ?string $transfer_type = null): int + { + global $db; + + $user_id = max(0, $user_id); + if ($user_id < 1) { + return 0; + } + + try { + $normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== '' + ? $this->validateTransferType($transfer_type) + : null; + } catch (Exception) { + return 0; + } + + $transfer_condition = ''; + if ($normalized_transfer_type !== null) { + $transfer_condition = "AND q.transfer_type = '" . $db->escape_string($normalized_transfer_type) . "'"; + } + + $sql = "INSERT INTO economic_transfer_queue_job_dismissals (queue_job_id, user_id, dismissed_status, dismissed_at) + SELECT q.id, $user_id, q.status, NOW() + FROM economic_transfer_queue_jobs q + LEFT JOIN economic_transfer_queue_job_dismissals d + ON d.queue_job_id = q.id + AND d.user_id = $user_id + AND d.dismissed_status = q.status + WHERE q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "') + $transfer_condition + AND d.queue_job_id IS NULL + ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()"; + $db->query($sql); + return max(0, (int)($db->affected_rows ?? 0)); + } + /** * @throws Exception */ @@ -193,6 +319,8 @@ class economic_transfer_queue throw new Exception('Failed to retry queue job'); } + $this->clearDismissalsForJob($job_id); + $job = $this->getJobById($job_id); if ($job === null) { throw new Exception('Retry updated job could not be loaded'); @@ -480,6 +608,18 @@ class economic_transfer_queue ]; } + private function clearDismissalsForJob(int $job_id): void + { + global $db; + + $job_id = max(0, $job_id); + if ($job_id < 1) { + return; + } + + $db->query("DELETE FROM economic_transfer_queue_job_dismissals WHERE queue_job_id = $job_id"); + } + /** * Release jobs stuck in PROCESSING due to crashes or killed workers. */ diff --git a/services/nginx/app/classes/economic_transfer_queue_details_summary.php b/services/nginx/app/classes/economic_transfer_queue_details_summary.php index 67bf0947..f0fbaae6 100644 --- a/services/nginx/app/classes/economic_transfer_queue_details_summary.php +++ b/services/nginx/app/classes/economic_transfer_queue_details_summary.php @@ -27,12 +27,20 @@ class economic_transfer_queue_details_summary 'customer_number' => self::toPositiveInt( $result['customer_number'] ?? $result['user']['customer_number'] + ?? $payload['customer_number'] + ?? $payload['customer']['customer_number'] ?? null ), 'name' => self::toNonEmptyString( $result['customer_name'] + ?? $result['user']['customer_name'] + ?? $result['user']['display_name'] ?? $result['user']['name'] ?? $result['user']['company_name'] + ?? $payload['customer_name'] + ?? $payload['customer']['customer_name'] + ?? $payload['customer']['display_name'] + ?? $payload['customer']['name'] ?? null ), ], diff --git a/services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php b/services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php index 1c473a64..c0abfc56 100644 --- a/services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php +++ b/services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php @@ -42,6 +42,18 @@ class economic_transfer_queue_schema_bootstrap ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" ); + $db->query( + "CREATE TABLE IF NOT EXISTS economic_transfer_queue_job_dismissals ( + queue_job_id BIGINT UNSIGNED NOT NULL, + user_id INT NOT NULL, + dismissed_status VARCHAR(32) NOT NULL, + dismissed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (queue_job_id, user_id), + INDEX idx_economic_transfer_queue_job_dismissals_user_status (user_id, dismissed_status), + INDEX idx_economic_transfer_queue_job_dismissals_job (queue_job_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + self::$initialized = true; } } diff --git a/services/nginx/app/classes/economic_v2_distribution_service.php b/services/nginx/app/classes/economic_v2_distribution_service.php index 6f88dbed..162d51fa 100644 --- a/services/nginx/app/classes/economic_v2_distribution_service.php +++ b/services/nginx/app/classes/economic_v2_distribution_service.php @@ -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 $department_counts + * @return array + */ + 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)) { diff --git a/services/nginx/app/classes/edgegateway.php b/services/nginx/app/classes/edgegateway.php index 9d0b5ee5..67cc2202 100644 --- a/services/nginx/app/classes/edgegateway.php +++ b/services/nginx/app/classes/edgegateway.php @@ -48,4 +48,42 @@ class edgegateway implements universal_module_i $configured = trim((string)$this->config->default_update_window->getVariableValue()); return $configured !== '' ? $configured : '02:00-04:00'; } + + public function brokerUrl(): string + { + $configured = trim((string)$this->config->broker_url->getVariableValue()); + if ($configured !== '') { + return rtrim($configured, '/'); + } + + $fallback = trim((string)(getenv('EDGE_BROKER_URL') ?: '')); + return $fallback !== '' ? rtrim($fallback, '/') : ''; + } + + public function publicBrokerUrl(): string + { + $configured = trim((string)$this->config->public_broker_url->getVariableValue()); + if ($configured !== '') { + return rtrim($configured, '/'); + } + + $fallback = trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: '')); + return $fallback !== '' ? rtrim($fallback, '/') : ''; + } + + public function brokerAuthMode(): string + { + $configured = trim((string)$this->config->broker_auth_mode->getVariableValue()); + return $configured !== '' ? $configured : 'manager'; + } + + public function brokerSharedSecret(): string + { + $configured = trim((string)$this->config->broker_shared_secret->getVariableValue()); + if ($configured !== '') { + return $configured; + } + + return trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: '')); + } } diff --git a/services/nginx/app/classes/error_report_schema_bootstrap.php b/services/nginx/app/classes/error_report_schema_bootstrap.php new file mode 100644 index 00000000..3f2ad1f7 --- /dev/null +++ b/services/nginx/app/classes/error_report_schema_bootstrap.php @@ -0,0 +1,76 @@ +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; + } +} diff --git a/services/nginx/app/classes/error_report_service.php b/services/nginx/app/classes/error_report_service.php new file mode 100644 index 00000000..10507955 --- /dev/null +++ b/services/nginx/app/classes/error_report_service.php @@ -0,0 +1,567 @@ +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 : []; + } +} diff --git a/services/nginx/app/classes/error_report_store.php b/services/nginx/app/classes/error_report_store.php new file mode 100644 index 00000000..7dc93fc0 --- /dev/null +++ b/services/nginx/app/classes/error_report_store.php @@ -0,0 +1,47 @@ + '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); + } +} diff --git a/services/nginx/app/classes/failover.php b/services/nginx/app/classes/failover.php new file mode 100644 index 00000000..65f7c14f --- /dev/null +++ b/services/nginx/app/classes/failover.php @@ -0,0 +1,17 @@ +config = new failover_c(); + } +} diff --git a/services/nginx/app/classes/form.php b/services/nginx/app/classes/form.php index 9a3a6132..e2c5ac9e 100644 --- a/services/nginx/app/classes/form.php +++ b/services/nginx/app/classes/form.php @@ -12,8 +12,6 @@ use Exception; use forms\form_helper_c; use forms\objects\book_interior_wash_f; use forms\objects\book_wash_f; -use forms\objects\complete_booking_f; -use forms\objects\generate_booking_wash_certificate_f; use objects\form_submissions_o; use traits\form_t; @@ -30,25 +28,12 @@ class form * @var book_wash_f $book_wash The BOOK_WASH form */ public book_wash_f $book_wash; - /** - * The GENERATE_BOOKING_CERTIFICATE form - * @var generate_booking_wash_certificate_f $generate_booking_wash_certificate The GENERATE_BOOKING_CERTIFICATE form - */ - public generate_booking_wash_certificate_f $generate_booking_wash_certificate; - - /** - * The COMPLETE_BOOKING_WITHOUT_WASH_CERTIFICATE form - * @var complete_booking_f $complete_booking_without_wash_certificate The COMPLETE_BOOKING_WITHOUT_WASH_CERTIFICATE form - */ - public complete_booking_f $complete_booking_without_wash_certificate; public $last_submitted_form; public form_submissions_o $form_submission; public function __construct() { $this->book_wash = new book_wash_f(); - $this->generate_booking_wash_certificate = new generate_booking_wash_certificate_f(); - $this->complete_booking_without_wash_certificate = new complete_booking_f(); } /** @@ -109,4 +94,4 @@ class form } throw new Exception('The form was not found'); } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/hetzner_cloud_client.php b/services/nginx/app/classes/hetzner_cloud_client.php new file mode 100644 index 00000000..cae49d60 --- /dev/null +++ b/services/nginx/app/classes/hetzner_cloud_client.php @@ -0,0 +1,151 @@ +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; + } +} diff --git a/services/nginx/app/classes/invoice_period_flag_schema_bootstrap.php b/services/nginx/app/classes/invoice_period_flag_schema_bootstrap.php new file mode 100644 index 00000000..ee25fd5f --- /dev/null +++ b/services/nginx/app/classes/invoice_period_flag_schema_bootstrap.php @@ -0,0 +1,64 @@ +query( + "CREATE TABLE IF NOT EXISTS invoice_period_flags ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + source VARCHAR(32) NOT NULL, + severity VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + target_type VARCHAR(64) NOT NULL, + target_id BIGINT NOT NULL, + field VARCHAR(64) NULL, + customer_number INT NULL, + order_id BIGINT NULL, + order_item_id BIGINT NULL, + invoice_collection_id BIGINT NULL, + xlvask_usage_log_id BIGINT NULL, + definition_key VARCHAR(128) NULL, + fingerprint VARCHAR(191) NULL, + reason TEXT NULL, + status_reason TEXT NULL, + context_json JSON NULL, + created_by INT NULL, + status_changed_by INT NULL, + status_changed_at DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_invoice_period_flags_auto_fingerprint (source, fingerprint), + KEY idx_invoice_period_flags_target (target_type, target_id, status), + KEY idx_invoice_period_flags_customer_status (customer_number, status), + KEY idx_invoice_period_flags_source_status (source, status), + KEY idx_invoice_period_flags_order (order_id), + KEY idx_invoice_period_flags_order_item (order_item_id), + KEY idx_invoice_period_flags_invoice_collection (invoice_collection_id), + KEY idx_invoice_period_flags_xlvask (xlvask_usage_log_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + products_schema_bootstrap::ensureTables(); + xlvask_usage_logs_schema_bootstrap::ensureTables(); + + self::$initialized = true; + } +} diff --git a/services/nginx/app/classes/invoice_period_flag_service.php b/services/nginx/app/classes/invoice_period_flag_service.php new file mode 100644 index 00000000..99e99eb8 --- /dev/null +++ b/services/nginx/app/classes/invoice_period_flag_service.php @@ -0,0 +1,2105 @@ +normalizeField($targetType, $payload['field'] ?? null); + $reason = trim((string)($payload['reason'] ?? '')); + + if (!in_array($targetType, self::VALID_TARGETS, true)) { + throw new \InvalidArgumentException('Invalid flag target type.'); + } + if ($targetId < 1) { + throw new \InvalidArgumentException('Flag target id is required.'); + } + if ($reason === '') { + throw new \InvalidArgumentException('Manual flag reason is required.'); + } + + $context = $this->resolveTargetContext($targetType, $targetId, $field); + $contextJson = $this->jsonSql($context); + + $sql = sprintf( + "INSERT INTO invoice_period_flags + (source, severity, status, target_type, target_id, field, customer_number, order_id, order_item_id, + invoice_collection_id, xlvask_usage_log_id, reason, context_json, created_by) + VALUES + ('%s', 'red', 'active', '%s', %d, %s, %s, %s, %s, %s, %s, '%s', %s, %s)", + self::SOURCE_MANUAL, + $db->escape_string($targetType), + $targetId, + $this->nullableStringSql($field), + $this->nullableIntSql($context['customer_number'] ?? null), + $this->nullableIntSql($context['order_id'] ?? null), + $this->nullableIntSql($context['order_item_id'] ?? null), + $this->nullableIntSql($context['invoice_collection_id'] ?? null), + $this->nullableIntSql($context['xlvask_usage_log_id'] ?? null), + $db->escape_string($reason), + $contextJson, + $this->nullableIntSql($userId > 0 ? $userId : null) + ); + $db->query($sql); + + return $this->getStoredFlag((int)$db->insert_id()); + } + + public function updateManualFlagStatus(int $id, string $status, ?string $reason, int $userId): array + { + global $db; + + $status = trim($status); + if (!in_array($status, self::VALID_STATUSES, true) || $status === self::STATUS_ACTIVE) { + throw new \InvalidArgumentException('Invalid manual flag status.'); + } + if ($id < 1) { + throw new \InvalidArgumentException('Flag id is required.'); + } + + $sql = sprintf( + "UPDATE invoice_period_flags + SET status = '%s', + status_reason = %s, + status_changed_by = %s, + status_changed_at = NOW() + WHERE id = %d AND source = '%s'", + $db->escape_string($status), + $this->nullableStringSql($reason), + $this->nullableIntSql($userId > 0 ? $userId : null), + $id, + self::SOURCE_MANUAL + ); + $db->query($sql); + + return $this->getStoredFlag($id); + } + + public function updateAutomaticFlagStatus(array $payload, int $userId): array + { + global $db; + + $fingerprint = trim((string)($payload['fingerprint'] ?? '')); + $status = trim((string)($payload['status'] ?? '')); + $targetType = trim((string)($payload['target_type'] ?? '')); + $targetId = (int)($payload['target_id'] ?? 0); + $field = $this->normalizeField($targetType, $payload['field'] ?? null); + $definitionKey = trim((string)($payload['definition_key'] ?? '')); + $reason = isset($payload['reason']) ? trim((string)$payload['reason']) : null; + + if ($fingerprint === '') { + throw new \InvalidArgumentException('Automatic flag fingerprint is required.'); + } + if (!in_array($status, self::VALID_STATUSES, true) || $status === self::STATUS_ACTIVE) { + throw new \InvalidArgumentException('Invalid automatic flag status.'); + } + if (!in_array($targetType, self::VALID_TARGETS, true)) { + throw new \InvalidArgumentException('Invalid automatic flag target type.'); + } + if ($targetId < 1) { + throw new \InvalidArgumentException('Automatic flag target id is required.'); + } + + $context = $this->resolveTargetContext($targetType, $targetId, $field); + $contextJson = $this->jsonSql($context); + + $sql = sprintf( + "INSERT INTO invoice_period_flags + (source, severity, status, target_type, target_id, field, customer_number, order_id, order_item_id, + invoice_collection_id, xlvask_usage_log_id, definition_key, fingerprint, status_reason, context_json, + created_by, status_changed_by, status_changed_at) + VALUES + ('%s', 'yellow', '%s', '%s', %d, %s, %s, %s, %s, %s, %s, %s, '%s', %s, %s, %s, %s, NOW()) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + target_type = VALUES(target_type), + target_id = VALUES(target_id), + field = VALUES(field), + customer_number = VALUES(customer_number), + order_id = VALUES(order_id), + order_item_id = VALUES(order_item_id), + invoice_collection_id = VALUES(invoice_collection_id), + xlvask_usage_log_id = VALUES(xlvask_usage_log_id), + definition_key = VALUES(definition_key), + status_reason = VALUES(status_reason), + context_json = VALUES(context_json), + status_changed_by = VALUES(status_changed_by), + status_changed_at = NOW()", + self::SOURCE_AUTOMATIC, + $db->escape_string($status), + $db->escape_string($targetType), + $targetId, + $this->nullableStringSql($field), + $this->nullableIntSql($context['customer_number'] ?? null), + $this->nullableIntSql($context['order_id'] ?? null), + $this->nullableIntSql($context['order_item_id'] ?? null), + $this->nullableIntSql($context['invoice_collection_id'] ?? null), + $this->nullableIntSql($context['xlvask_usage_log_id'] ?? null), + $this->nullableStringSql($definitionKey !== '' ? $definitionKey : null), + $db->escape_string($fingerprint), + $this->nullableStringSql($reason), + $contextJson, + $this->nullableIntSql($userId > 0 ? $userId : null), + $this->nullableIntSql($userId > 0 ? $userId : null) + ); + $db->query($sql); + + return $this->getStoredAutomaticFlag($fingerprint); + } + + /** + * @param array>> $types + * @param int[]|null $onlyCustomerNumbers + * @return array>> + */ + public function applyFlagsToPeriodTypes(array $types, string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array + { + global $response; + $context = $this->buildPeriodContext($types, $dateFrom, $dateTo, $onlyCustomerNumbers); + $manualFlags = $this->getManualFlagsForPeriod($context, $dateFrom, $dateTo, $onlyCustomerNumbers); + $automaticFlags = $this->getAutomaticFlagsForPeriod($dateFrom, $dateTo, $onlyCustomerNumbers); + $automaticFlags = $this->filterSuppressedAutomaticFlags($automaticFlags); + $allFlags = array_merge($manualFlags, $automaticFlags); + + $types = $this->ensureFlagOnlyCustomers($types, $allFlags); + $flagsByCustomerNumber = []; + foreach ($allFlags as $flag) { + $customerNumber = (int)($flag['customer_number'] ?? 0); + if ($customerNumber < 1) { + continue; + } + $flagsByCustomerNumber[$customerNumber][] = $flag; + } + + foreach ($types as $typeName => $customers) { + foreach ($customers as $index => $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + $flags = $this->flagsForCustomerCard( + $customer, + $flagsByCustomerNumber[$customerNumber] ?? [], + (string)$typeName + ); + usort($flags, [$this, 'sortFlags']); + $types[$typeName][$index]['flags'] = array_values($flags); + $types[$typeName][$index]['flag_counts'] = $this->countFlags($flags); + $types[$typeName][$index]['status_indicator'] = $this->statusIndicatorForCustomer( + $types[$typeName][$index], + $flags + ); + } + } + + return $types; + } + + private function flagsForCustomerCard(array $customer, array $flags, string $typeName = ''): array + { + $transactionIds = []; + $invoiceCollectionIds = []; + foreach (($customer['transactions'] ?? []) as $transaction) { + $orderId = (int)($transaction['id'] ?? 0); + if ($orderId > 0) { + $transactionIds[$orderId] = true; + } + + $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + $invoiceCollectionIds[$invoiceCollectionId] = true; + } + } + + return array_values(array_filter($flags, function (array $flag) use ($customer, $transactionIds, $invoiceCollectionIds, $typeName): bool { + return $this->flagBelongsToCustomerCard($customer, $flag, $transactionIds, $invoiceCollectionIds, $typeName); + })); + } + + private function flagBelongsToCustomerCard( + array $customer, + array $flag, + array $transactionIds, + array $invoiceCollectionIds, + string $typeName = '' + ): bool { + if ((string)($flag['status'] ?? self::STATUS_ACTIVE) !== self::STATUS_ACTIVE) { + return false; + } + + $customerNumber = (int)($customer['customer_number'] ?? 0); + $targetType = (string)($flag['target_type'] ?? ''); + if ($targetType === 'customer') { + return (int)($flag['customer_number'] ?? $flag['target_id'] ?? 0) === $customerNumber; + } + + if ($targetType === 'xlvask_usage_log') { + return $typeName === 'all' && (int)($flag['customer_number'] ?? 0) === $customerNumber; + } + + if (in_array($targetType, ['order', 'order_field', 'order_item', 'order_item_field'], true)) { + $orderId = (int)($flag['order_id'] ?? $flag['context']['order_id'] ?? 0); + if ($orderId < 1 && in_array($targetType, ['order', 'order_field'], true)) { + $orderId = (int)($flag['target_id'] ?? 0); + } + return $orderId > 0 && isset($transactionIds[$orderId]); + } + + if ($targetType === 'collected_order_invoice') { + $invoiceCollectionId = (int)( + $flag['invoice_collection_id'] + ?? $flag['context']['invoice_collection_id'] + ?? $flag['target_id'] + ?? 0 + ); + return $invoiceCollectionId > 0 && isset($invoiceCollectionIds[$invoiceCollectionId]); + } + + return false; + } + + private function getStoredFlag(int $id): array + { + global $db; + + $result = $db->query("SELECT * FROM invoice_period_flags WHERE id = {$id} LIMIT 1"); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : null; + if (!$row) { + throw new \RuntimeException('Flag not found.'); + } + + return $this->formatStoredFlag($row); + } + + private function getStoredAutomaticFlag(string $fingerprint): array + { + global $db; + + $fingerprint = $db->escape_string($fingerprint); + $result = $db->query( + "SELECT * FROM invoice_period_flags + WHERE source = '" . self::SOURCE_AUTOMATIC . "' AND fingerprint = '{$fingerprint}' + LIMIT 1" + ); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : null; + if (!$row) { + throw new \RuntimeException('Automatic flag decision not found.'); + } + + return $this->formatStoredFlag($row); + } + + public function warmManualFlagsCache(): void + { + global $db; + + $result = $db->query( + "SELECT * FROM invoice_period_flags + WHERE source = '" . self::SOURCE_MANUAL . "' + AND status = '" . self::STATUS_ACTIVE . "' + ORDER BY created_at ASC, id ASC" + ); + + $flags = []; + if ($result) { + while ($row = $result->fetch_assoc()) { + $flags[] = $this->formatStoredFlag($row); + } + } + + try { + (new redis())->cache_invoice_period_manual_flags($flags); + } catch (Throwable) { + } + } + + private function formatStoredFlag(array $row): array + { + $context = []; + if (!empty($row['context_json'])) { + $decoded = json_decode((string)$row['context_json'], true); + $context = is_array($decoded) ? $decoded : []; + } + + return [ + 'id' => (int)$row['id'], + 'source' => (string)$row['source'], + 'severity' => (string)$row['severity'], + 'status' => (string)$row['status'], + 'target_type' => (string)$row['target_type'], + 'target_id' => (int)$row['target_id'], + 'field' => $row['field'], + 'customer_number' => $row['customer_number'] === null ? null : (int)$row['customer_number'], + 'order_id' => $row['order_id'] === null ? null : (int)$row['order_id'], + 'order_item_id' => $row['order_item_id'] === null ? null : (int)$row['order_item_id'], + 'invoice_collection_id' => $row['invoice_collection_id'] === null ? null : (int)$row['invoice_collection_id'], + 'xlvask_usage_log_id' => $row['xlvask_usage_log_id'] === null ? null : (int)$row['xlvask_usage_log_id'], + 'definition_key' => $row['definition_key'], + 'fingerprint' => $row['fingerprint'], + 'reason' => $row['reason'], + 'status_reason' => $row['status_reason'], + 'context' => $context, + 'created_by' => $row['created_by'] === null ? null : (int)$row['created_by'], + 'created_by_name' => $this->getUserDisplayName($row['created_by'] === null ? null : (int)$row['created_by']), + 'status_changed_by' => $row['status_changed_by'] === null ? null : (int)$row['status_changed_by'], + 'status_changed_at' => $row['status_changed_at'], + 'created_at' => $row['created_at'], + 'updated_at' => $row['updated_at'], + 'message' => (string)($row['reason'] ?? ''), + ]; + } + + private function getCachedManualFlags(): array + { + try { + $flags = (new redis())->get_invoice_period_manual_flags(); + } catch (Throwable) { + $flags = null; + } + + if (!is_array($flags)) { + // Cache miss — warm on demand and re-fetch + $this->warmManualFlagsCache(); + try { + $flags = (new redis())->get_invoice_period_manual_flags(); + } catch (Throwable) { + return []; + } + if (!is_array($flags)) { + return []; + } + } + + return array_values(array_filter($flags, static function ($flag): bool { + return is_array($flag); + })); + } + + private function buildPeriodContext(array $types, string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array + { + $customerNumbers = []; + $orderIds = []; + $invoiceCollectionIds = []; + $orderToCustomer = []; + $invoiceCollectionToCustomer = []; + + foreach ($types as $customers) { + foreach ($customers as $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + if ($customerNumber > 0) { + $customerNumbers[$customerNumber] = true; + } + foreach (($customer['transactions'] ?? []) as $transaction) { + $orderId = (int)($transaction['id'] ?? 0); + if ($orderId > 0) { + $orderIds[$orderId] = true; + $orderToCustomer[$orderId] = $customerNumber; + } + $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + $invoiceCollectionIds[$invoiceCollectionId] = true; + $invoiceCollectionToCustomer[$invoiceCollectionId] = $customerNumber; + } + } + } + } + + $orderItemToOrder = $this->getOrderItemToOrderMap(array_keys($orderIds)); + $xlvaskPeriodRows = $this->getXlVaskPeriodRows($dateFrom, $dateTo, $onlyCustomerNumbers); + + return [ + 'customer_numbers' => array_keys($customerNumbers), + 'order_ids' => array_keys($orderIds), + 'invoice_collection_ids' => array_keys($invoiceCollectionIds), + 'order_to_customer' => $orderToCustomer, + 'invoice_collection_to_customer' => $invoiceCollectionToCustomer, + 'order_item_to_order' => $orderItemToOrder, + 'xlvask_period_rows' => $xlvaskPeriodRows, + ]; + } + + private function getManualFlagsForPeriod(array $context, string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array + { + if ($this->manualFlagsInstanceCache === null) { + $this->manualFlagsInstanceCache = $this->getCachedManualFlags(); + } + $cachedFlags = $this->manualFlagsInstanceCache; + if (empty($cachedFlags)) { + return []; + } + + $allowedCustomerNumbers = $onlyCustomerNumbers !== null + ? array_fill_keys(array_map('intval', $onlyCustomerNumbers), true) + : null; + $periodCustomerNumbers = array_fill_keys(array_map('intval', $context['customer_numbers']), true); + $orderToCustomer = $context['order_to_customer']; + $invoiceCollectionToCustomer = $context['invoice_collection_to_customer']; + $orderItemToOrder = $context['order_item_to_order']; + $xlvaskRows = []; + foreach ($context['xlvask_period_rows'] as $row) { + $xlvaskRows[(int)$row['id']] = $row; + } + + $flags = []; + foreach ($cachedFlags as $row) { + $targetType = (string)$row['target_type']; + $targetId = (int)$row['target_id']; + $customerNumber = null; + + if ($targetType === 'customer') { + if (!isset($periodCustomerNumbers[$targetId])) { + continue; + } + $customerNumber = $targetId; + } elseif ($targetType === 'order' || $targetType === 'order_field') { + if (!isset($orderToCustomer[$targetId])) { + continue; + } + $customerNumber = (int)$orderToCustomer[$targetId]; + } elseif ($targetType === 'order_item' || $targetType === 'order_item_field') { + $orderId = (int)($orderItemToOrder[$targetId] ?? 0); + if ($orderId < 1 || !isset($orderToCustomer[$orderId])) { + continue; + } + $customerNumber = (int)$orderToCustomer[$orderId]; + } elseif ($targetType === 'collected_order_invoice') { + if (!isset($invoiceCollectionToCustomer[$targetId])) { + continue; + } + $customerNumber = (int)$invoiceCollectionToCustomer[$targetId]; + } elseif ($targetType === 'xlvask_usage_log') { + if (!isset($xlvaskRows[$targetId])) { + continue; + } + $customerNumber = (int)($xlvaskRows[$targetId]['customer_number'] ?? 0); + } + + if ($customerNumber === null || $customerNumber < 1) { + continue; + } + if ($allowedCustomerNumbers !== null && !isset($allowedCustomerNumbers[$customerNumber])) { + continue; + } + + $flag = $row; + $flag['customer_number'] = $customerNumber; + $flag['message'] = (string)$flag['reason']; + $flags[] = $flag; + } + + return $flags; + } + + private function getAutomaticFlagsForPeriod(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array + { + try { + $flags = (new redis())->get_invoice_period_automatic_flags($dateFrom, $dateTo); + } catch (Throwable) { + return []; + } + + if (!is_array($flags)) { + // Cache miss — enqueue for warming on the next cron run + try { + (new redis())->enqueue_invoice_period_warming($dateFrom, $dateTo); + } catch (Throwable) { + } + return []; + } + + if ($onlyCustomerNumbers === null) { + return $flags; + } + + $allowed = array_fill_keys(array_map('intval', $onlyCustomerNumbers), true); + return array_values(array_filter($flags, static function (array $flag) use ($allowed): bool { + return isset($allowed[(int)($flag['customer_number'] ?? 0)]); + })); + } + + public function warmAutomaticFlagsForPeriod(string $dateFrom, string $dateTo): void + { + $rows = $this->getPeriodOrderItemRows($dateFrom, $dateTo, null); + $attributes = $this->getCustomerAttributes(null); + + $flags = array_merge( + $this->detectCustomerRuleViolations($rows, $attributes), + $this->detectPriceMismatches($rows), + $this->detectAbnormalQuantities($rows, $dateFrom, $dateTo), + $this->detectVehicleTypeMismatches($rows, $dateFrom), + $this->detectMissingXlVaskLinks($dateFrom, $dateTo, null) + ); + + try { + (new redis())->cache_invoice_period_automatic_flags($dateFrom, $dateTo, $flags); + } catch (Throwable) { + } + } + + private function filterSuppressedAutomaticFlags(array $flags): array + { + global $db; + + $fingerprints = array_values(array_unique(array_filter(array_map( + static fn(array $flag): string => (string)($flag['fingerprint'] ?? ''), + $flags + )))); + + if (empty($fingerprints)) { + return $flags; + } + + $in = implode(',', array_map(static function (string $fingerprint) use ($db): string { + return "'" . $db->escape_string($fingerprint) . "'"; + }, $fingerprints)); + + $suppressed = []; + $result = $db->query( + "SELECT fingerprint FROM invoice_period_flags + WHERE source = '" . self::SOURCE_AUTOMATIC . "' + AND status IN ('resolved', 'ignored', 'false_positive') + AND fingerprint IN ({$in})" + ); + if ($result) { + while ($row = $result->fetch_assoc()) { + $suppressed[(string)$row['fingerprint']] = true; + } + } + + return array_values(array_filter($flags, static function (array $flag) use ($suppressed): bool { + return !isset($suppressed[(string)($flag['fingerprint'] ?? '')]); + })); + } + + private function getPeriodOrderItemRows(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array + { + try { + $rows = (new redis())->get_invoice_period_order_item_rows($dateFrom, $dateTo); + } catch (Throwable) { + return []; + } + + if (!is_array($rows)) { + return []; + } + + $this->seedOrderItemsPreviewCacheFromRows($rows); + + if ($onlyCustomerNumbers === null) { + return $rows; + } + + $allowed = array_fill_keys(array_map('intval', $onlyCustomerNumbers), true); + return array_values(array_filter($rows, static function (array $row) use ($allowed): bool { + return isset($allowed[(int)($row['customer_number'] ?? 0)]); + })); + } + + public function warmOrderItemRowsForPeriod(string $dateFrom, string $dateTo): void + { + $rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo); + try { + (new redis())->cache_invoice_period_order_item_rows($dateFrom, $dateTo, $rows); + } catch (Throwable) { + } + } + + private function fetchOrderItemRowsFromDb(string $dateFrom, string $dateTo): array + { + global $db; + + $escapedDateFrom = $db->escape_string($dateFrom); + $escapedDateTo = $db->escape_string($dateTo); + + $sql = " + SELECT + o.id AS order_id, + o.customer_id AS customer_number, + u.id AS user_id, + u.display_name AS customer_name, + o.reference AS order_reference, + o.po AS order_po, + o.notes AS order_notes, + o.department_id, + o.reg_1, + o.invoice_collection_id, + o.wash_id, + o.safety_seal, + o.created_at AS order_created_at, + oi.id AS order_item_id, + oi.product_id, + oi.reference AS item_reference, + oi.notes AS item_notes, + oi.price AS item_price, + oi.quantity AS item_quantity, + oi.related_item_id, + oi.include_in_invoice AS item_include_in_invoice, + p.name AS product_name, + p.price AS product_base_price, + p.category AS product_category, + p.apply_category_discount, + p.is_wash, + p.subscription_allowed, + p.max_quantity_per_order, + c.name AS category_name, + pdp.price AS department_price, + product_discount.percentage AS product_discount_percentage, + category_discount.percentage AS category_discount_percentage + FROM orders o + LEFT JOIN ( + SELECT customer_number, MIN(id) AS id, MAX(display_name) AS display_name + FROM users + WHERE customer_number IS NOT NULL AND customer_number <> 0 + GROUP BY customer_number + ) u ON u.customer_number = o.customer_id + LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '') + LEFT JOIN products p ON p.id = oi.product_id + LEFT JOIN categories c ON c.id = p.category + LEFT JOIN product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id + LEFT JOIN ( + SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage + FROM price_overrides po + INNER JOIN users discount_user ON discount_user.id = po.user_id + WHERE po.is_category = 0 + GROUP BY discount_user.customer_number, po.product_or_category_id + ) product_discount + ON product_discount.customer_number = o.customer_id + AND product_discount.product_or_category_id = p.id + LEFT JOIN ( + SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage + FROM price_overrides po + INNER JOIN users discount_user ON discount_user.id = po.user_id + WHERE po.is_category = 1 + GROUP BY discount_user.customer_number, po.product_or_category_id + ) category_discount + ON category_discount.customer_number = o.customer_id + AND category_discount.product_or_category_id = p.category + WHERE o.created_at BETWEEN '{$escapedDateFrom}' AND '{$escapedDateTo}' + AND o.deleted_at IS NULL + ORDER BY o.customer_id, o.id, oi.id"; + + $result = $db->query($sql); + $rows = $result ? $db->fetch_all($result) : []; + $certificateAttachmentOrderIds = $this->getWashCertificateAttachmentOrderIds(array_column($rows, 'order_id')); + + foreach ($rows as &$row) { + $orderId = (int)($row['order_id'] ?? 0); + $row['has_wash_certificate_attachment'] = isset($certificateAttachmentOrderIds[$orderId]) ? 1 : 0; + } + unset($row); + $this->seedOrderItemsPreviewCacheFromRows($rows); + + return $rows; + } + + private function getWashCertificateAttachmentOrderIds(array $orderIds): array + { + global $db; + + $orderIds = array_values(array_unique(array_filter( + array_map('intval', $orderIds), + static fn(int $orderId): bool => $orderId > 0 + ))); + if (empty($orderIds) || !$this->tableExists('object_attachments')) { + return []; + } + + $objectTypes = []; + foreach (['orders', '`orders`'] as $type) { + $objectTypes[] = "'" . $db->escape_string($type) . "'"; + } + $in = implode(',', $orderIds); + $result = $db->query( + "SELECT object_id, content + FROM object_attachments + WHERE object_type IN (" . implode(',', $objectTypes) . ") + AND object_id IN ({$in}) + AND deleted_at IS NULL" + ); + + $attached = []; + if (!$result) { + return $attached; + } + + while ($row = $result->fetch_assoc()) { + $content = json_decode((string)($row['content'] ?? ''), true); + $other = is_array($content) ? ($content['other'] ?? null) : null; + if (is_string($other) && strtolower(trim($other)) === 'wash_certificate') { + $attached[(int)$row['object_id']] = true; + } + } + + return $attached; + } + + private function getCustomerAttributes(?array $onlyCustomerNumbers): array + { + global $db; + + $customerFilter = $this->customerFilterSql('u.customer_number', $onlyCustomerNumbers); + $result = $db->query( + "SELECT u.customer_number, ca.attribute + FROM customer_attributes ca + JOIN users u ON u.id = ca.user_id + WHERE 1=1 {$customerFilter}" + ); + + $attributes = []; + if (!$result) { + return $attributes; + } + + while ($row = $result->fetch_assoc()) { + $customerNumber = (int)$row['customer_number']; + $attributes[$customerNumber][(string)$row['attribute']] = true; + } + + return $attributes; + } + + private function detectCustomerRuleViolations(array $rows, array $attributes): array + { + $flags = []; + $orders = []; + $collectionOrders = []; + + foreach ($rows as $row) { + $customerNumber = (int)$row['customer_number']; + $orderId = (int)$row['order_id']; + if ($orderId > 0 && !isset($orders[$orderId])) { + $orders[$orderId] = $row; + } + $invoiceCollectionId = (int)($row['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + $collectionOrders[$customerNumber][$invoiceCollectionId][$orderId] = true; + } + + if (!$this->hasAttribute($attributes, $customerNumber, 'restrictAdditionalServices') + && !$this->hasAttribute($attributes, $customerNumber, 'restrictTankCleaning') + && !$this->hasAttribute($attributes, $customerNumber, 'restrictSpotFree') + && !$this->hasAttribute($attributes, $customerNumber, 'restrictInteriorCleaning') + && !$this->hasAttribute($attributes, $customerNumber, 'exemptFromAdministrationFee') + && !$this->hasAttribute($attributes, $customerNumber, 'onlyTankCleaning')) { + continue; + } + + if ((int)($row['order_item_id'] ?? 0) < 1) { + continue; + } + + $isTankCleaningProduct = $this->rowIsTankCleaningProduct($row); + + if ($this->hasAttribute($attributes, $customerNumber, 'restrictAdditionalServices') + && (int)($row['related_item_id'] ?? 0) > 0 + && (int)($row['item_price'] ?? 0) > 0) { + $flags[] = $this->automaticFlag( + 'customer_rule_restrict_addon_services', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + ['product' => $this->productLabel($row)], + $this->orderItemContext($row) + ); + } + + if ($this->hasAttribute($attributes, $customerNumber, 'restrictTankCleaning') && $isTankCleaningProduct) { + $flags[] = $this->automaticFlag( + 'customer_rule_restrict_tank_cleaning', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + ['product' => $this->productLabel($row)], + $this->orderItemContext($row) + ); + } + + if ($this->hasAttribute($attributes, $customerNumber, 'onlyTankCleaning') && !$isTankCleaningProduct) { + $flags[] = $this->automaticFlag( + 'customer_rule_only_tank_cleaning', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + ['product' => $this->productLabel($row)], + $this->orderItemContext($row) + ); + } + + $restrictedProducts = [ + 'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree']], + 'restrictInteriorCleaning' => ['customer_rule_restrict_interior_cleaning', ['interior', 'indvendig']], + 'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']], + ]; + + foreach ($restrictedProducts as $attribute => [$definitionKey, $terms]) { + if ($this->hasAttribute($attributes, $customerNumber, $attribute) + && $this->rowMatchesProductTerms($row, $terms)) { + $flags[] = $this->automaticFlag( + $definitionKey, + 'order_item', + (int)$row['order_item_id'], + null, + $row, + ['product' => $this->productLabel($row)], + $this->orderItemContext($row) + ); + } + } + } + + foreach ($orders as $orderId => $row) { + $customerNumber = (int)$row['customer_number']; + if ($this->hasAttribute($attributes, $customerNumber, 'requiresReferenceNumber') + && trim((string)($row['order_reference'] ?? '')) === '') { + $context = $this->orderContext($row); + $context['order_items'] = $this->getOrderItemsForPreview((int)$orderId); + $flags[] = $this->automaticFlag( + 'customer_rule_requires_reference', + 'order_field', + $orderId, + 'reference', + $row, + [], + $context + ); + } + if ($this->hasAttribute($attributes, $customerNumber, 'usePONumbers') + && trim((string)($row['order_po'] ?? '')) === '') { + $context = $this->orderContext($row); + $context['order_items'] = $this->getOrderItemsForPreview((int)$orderId); + $flags[] = $this->automaticFlag( + 'customer_rule_requires_po_number', + 'order_field', + $orderId, + 'po', + $row, + [], + $context + ); + } + } + + foreach ($collectionOrders as $customerNumber => $collections) { + if (!$this->hasAttribute($attributes, (int)$customerNumber, 'invoiceAllOrdersIndividually')) { + continue; + } + foreach ($collections as $invoiceCollectionId => $orderSet) { + if (count($orderSet) <= 1) { + continue; + } + $row = $orders[(int)array_key_first($orderSet)] ?? ['customer_number' => $customerNumber, 'invoice_collection_id' => $invoiceCollectionId]; + $flags[] = $this->automaticFlag( + 'customer_rule_invoice_all_orders_individually', + 'collected_order_invoice', + (int)$invoiceCollectionId, + null, + $row, + ['count' => count($orderSet)], + $this->invoiceCollectionContext($row) + ); + } + } + + return $this->dedupeAutomaticFlags($flags); + } + + private function detectPriceMismatches(array $rows): array + { + $this->preloadEconomicCustomerDiscounts($rows); + + $flags = []; + foreach ($rows as $row) { + $orderItemId = (int)($row['order_item_id'] ?? 0); + if ($orderItemId < 1 || !$this->isIncludedOrderItem($row)) { + continue; + } + + $expected = $this->calculateExpectedPrice($row); + $actual = (int)($row['item_price'] ?? 0); + if ($actual === $expected) { + continue; + } + + $context = $this->orderItemContext($row); + $context['actual_price'] = $actual; + $context['expected_price'] = $expected; + $context['expected_price_breakdown'] = $this->priceBreakdown($row, $expected); + $context['order_items'] = $this->getOrderItemsForPreview((int)$row['order_id']); + + $flags[] = $this->automaticFlag( + 'price_mismatch', + 'order_item_field', + $orderItemId, + 'price', + $row, + [ + 'product' => $this->productLabel($row), + 'expected' => 'expected', + 'actual_price' => $actual, + 'expected_price' => $expected, + ], + $context + ); + } + + return $flags; + } + + private function detectAbnormalQuantities(array $rows, string $dateFrom, string $dateTo): array + { + $flags = []; + $primaryByOrderProduct = []; + $orders = []; + $washCertificateByOrder = []; + $hasWashCertificateAttachmentByOrder = []; + $fixedPricingGroups = []; + $subscriptionGroups = []; + + foreach ($rows as $row) { + $orderId = (int)$row['order_id']; + $orderItemId = (int)($row['order_item_id'] ?? 0); + if ($orderId > 0 && !isset($orders[$orderId])) { + $orders[$orderId] = $row; + } + if ($orderId > 0 && $this->rowHasWashCertificateAttachment($row)) { + $hasWashCertificateAttachmentByOrder[$orderId] = true; + } + if ($orderItemId < 1) { + continue; + } + + if ($this->isPrimaryVehicleItem($row)) { + $primaryByOrderProduct[$orderId][(int)$row['product_id']][$orderItemId] ??= $row; + } + + $limit = (int)($row['max_quantity_per_order'] ?? 0); + if ($limit > 0 && (int)($row['item_quantity'] ?? 0) > $limit) { + $flags[] = $this->automaticFlag( + 'quantity_exceeds_product_limit', + 'order_item_field', + $orderItemId, + 'quantity', + $row, + [ + 'product' => $this->productLabel($row), + 'quantity' => (int)$row['item_quantity'], + 'limit' => $limit, + ], + $this->orderItemContext($row) + ['quantity_limit' => $limit] + ); + } + + if ($this->isWashCertificateProduct($row)) { + $washCertificateByOrder[$orderId][] = $row; + if (!$this->rowHasWashCertificateAttachment($row)) { + $context = $this->orderItemContext($row); + $context['order_items'] = $this->getOrderItemsForPreview((int)$row['order_id']); + $flags[] = $this->automaticFlag( + 'wash_certificate_item_without_certificate', + 'order_item', + $orderItemId, + null, + $row, + ['product' => $this->productLabel($row)], + $context + ); + } + } + + $monthKey = date('Y-m', strtotime((string)$row['order_created_at'])); + if ($this->rowMatchesProductTerms($row, ['fixed pricing', 'fastpris', 'fixed price'])) { + $fixedPricingGroups[(int)$row['customer_number']][$monthKey][] = $row; + } + if ($this->rowMatchesProductTerms($row, ['subscription', 'abonnement', 'vaskeabonnement'])) { + $reg = strtoupper(trim((string)($row['reg_1'] ?? ''))); + $subscriptionGroups[(int)$row['customer_number']][$reg][$monthKey][(int)$row['product_id']][] = $row; + } + } + + foreach ($primaryByOrderProduct as $orderId => $products) { + foreach ($products as $productId => $items) { + if (count($items) <= 1) { + continue; + } + $items = array_values($items); + $row = $items[0]; + $context = $this->orderContext($row); + $context['order_items'] = $this->getOrderItemsForPreview((int)$row['order_id']); + $flags[] = $this->automaticFlag( + 'multiple_identical_primary_vehicle_items', + 'order', + (int)$orderId, + null, + $row, + ['product' => $this->productLabel($row), 'count' => count($items)], + $context + ); + } + } + + foreach ($orders as $orderId => $row) { + if (isset($hasWashCertificateAttachmentByOrder[$orderId]) && empty($washCertificateByOrder[$orderId])) { + $context = $this->orderContext($row); + $context['order_items'] = $this->getOrderItemsForPreview((int)$orderId); + $flags[] = $this->automaticFlag( + 'wash_certificate_attached_without_item', + 'order', + (int)$orderId, + null, + $row, + [], + $context + ); + } + } + + foreach ($fixedPricingGroups as $customerGroups) { + foreach ($customerGroups as $items) { + if (count($items) <= 1) { + continue; + } + $row = $items[0]; + $flags[] = $this->automaticFlag( + 'multiple_fixed_pricing_items_same_month', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + ['count' => count($items)], + $this->orderItemContext($row) + ); + } + } + + foreach ($subscriptionGroups as $customerGroups) { + foreach ($customerGroups as $regGroups) { + foreach ($regGroups as $monthGroups) { + foreach ($monthGroups as $items) { + if (count($items) <= 1) { + continue; + } + $row = $items[0]; + $flags[] = $this->automaticFlag( + 'duplicate_vehicle_subscription_charge_same_month', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + ['product' => $this->productLabel($row), 'count' => count($items)], + $this->orderItemContext($row) + ); + } + } + } + } + + return $this->dedupeAutomaticFlags($flags); + } + + private function detectVehicleTypeMismatches(array $rows, string $dateFrom): array + { + global $db; + + $flags = []; + $primaryRows = array_values(array_filter($rows, fn(array $row): bool => $this->isPrimaryVehicleItem($row))); + if (empty($primaryRows)) { + return []; + } + + $vehicleTypeByCustomerReg = $this->getVehicleSubscriptionTypeMap($primaryRows); + foreach ($primaryRows as $row) { + $reg = strtoupper(trim((string)($row['reg_1'] ?? ''))); + $key = (int)$row['customer_number'] . '|' . $reg; + $expectedProductId = (int)($vehicleTypeByCustomerReg[$key]['product_id'] ?? 0); + $expectedProductName = (string)($vehicleTypeByCustomerReg[$key]['product_name'] ?? ''); + if ($expectedProductId > 0 + && !$this->primaryVehicleProductsMatch( + (int)$row['product_id'], + $this->productLabel($row), + $expectedProductId, + $expectedProductName + )) { + $flags[] = $this->automaticFlag( + 'vehicle_subscription_type_mismatch', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + [ + 'product' => $this->productLabel($row), + 'expected_product' => $expectedProductName !== '' ? $expectedProductName : (string)$expectedProductId, + ], + $this->orderItemContext($row) + ['expected_product_id' => $expectedProductId] + ); + } + } + + $history = $this->getPrimaryProductHistory($dateFrom, array_column($primaryRows, 'reg_1')); + foreach ($primaryRows as $row) { + $reg = strtoupper(trim((string)($row['reg_1'] ?? ''))); + if ($reg === '' || !isset($history[$reg])) { + continue; + } + $expectedProductId = (int)$history[$reg]['product_id']; + if ($this->primaryVehicleProductsMatch( + (int)$row['product_id'], + $this->productLabel($row), + $expectedProductId, + (string)$history[$reg]['product_name'] + )) { + continue; + } + $flags[] = $this->automaticFlag( + 'historical_primary_product_mismatch', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + [ + 'product' => $this->productLabel($row), + 'expected_product' => (string)$history[$reg]['product_name'], + ], + $this->orderItemContext($row) + [ + 'expected_product_id' => $expectedProductId, + 'expected_product_name' => $history[$reg]['product_name'], + 'history_count' => (int)$history[$reg]['count'], + ] + ); + } + + return $this->dedupeAutomaticFlags($flags); + } + + private function detectMissingXlVaskLinks(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array + { + $flags = []; + foreach ($this->getXlVaskPeriodRows($dateFrom, $dateTo, $onlyCustomerNumbers, true) as $row) { + $flags[] = $this->automaticFlag( + 'xlvask_missing_order_link', + 'xlvask_usage_log', + (int)$row['id'], + null, + [ + 'customer_number' => (int)$row['customer_number'], + 'customer_name' => (string)($row['customer_name'] ?? ''), + 'order_id' => null, + 'order_item_id' => null, + 'invoice_collection_id' => null, + 'xlvask_usage_log_id' => (int)$row['id'], + ], + [ + 'wash_id' => (string)$row['wash_id'], + 'registration_number' => (string)($row['registration_number'] ?? ''), + ], + [ + 'customer_number' => (int)$row['customer_number'], + 'customer_name' => (string)($row['customer_name'] ?? ''), + 'xlvask_usage_log_id' => (int)$row['id'], + 'wash_id' => (string)$row['wash_id'], + 'registration_number' => (string)($row['registration_number'] ?? ''), + 'start_time' => (string)($row['start_time'] ?? ''), + ] + ); + } + + return $flags; + } + + private function getXlVaskPeriodRows(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers, bool $onlyMissingLinks = false): array + { + global $db; + + if (!$this->tableExists('xlvask_usage_logs')) { + return []; + } + + $dateFrom = $db->escape_string($dateFrom); + $dateTo = $db->escape_string($dateTo); + $customerFilter = $this->customerFilterSql('CAST(x.CustomerId AS UNSIGNED)', $onlyCustomerNumbers); + $missingFilter = $onlyMissingLinks + ? "AND linked_order.id IS NULL" + : ''; + + $sql = " + SELECT + x.id, + x.WashId AS wash_id, + CAST(x.CustomerId AS UNSIGNED) AS customer_number, + COALESCE(u.display_name, x.Customer) AS customer_name, + x.RegistrationNumber AS registration_number, + x.StartTime AS start_time + FROM xlvask_usage_logs x + LEFT JOIN users u ON u.customer_number = CAST(x.CustomerId AS UNSIGNED) + LEFT JOIN orders linked_order + ON linked_order.wash_id = x.WashId + AND linked_order.deleted_at IS NULL + AND linked_order.created_at BETWEEN '{$dateFrom}' AND '{$dateTo}' + WHERE STR_TO_DATE(REPLACE(SUBSTRING(x.StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s') + BETWEEN '{$dateFrom}' AND '{$dateTo}' + AND COALESCE(x.ignored_at, '') = '' + AND COALESCE(x.FinishStatus, '') = '1' + AND CAST(x.CustomerId AS UNSIGNED) > 0 + {$customerFilter} + {$missingFilter}"; + + try { + $result = $db->query($sql); + return $result ? $db->fetch_all($result) : []; + } catch (Throwable) { + return []; + } + } + + private function automaticFlag( + string $definitionKey, + string $targetType, + int $targetId, + ?string $field, + array $row, + array $messageParams, + array $context + ): array { + $customerNumber = (int)($row['customer_number'] ?? $context['customer_number'] ?? 0); + $orderId = isset($context['order_id']) ? (int)$context['order_id'] : (isset($row['order_id']) ? (int)$row['order_id'] : null); + $orderItemId = isset($context['order_item_id']) ? (int)$context['order_item_id'] : (isset($row['order_item_id']) ? (int)$row['order_item_id'] : null); + $invoiceCollectionId = isset($context['invoice_collection_id']) ? (int)$context['invoice_collection_id'] : (isset($row['invoice_collection_id']) ? (int)$row['invoice_collection_id'] : null); + $xlvaskUsageLogId = isset($context['xlvask_usage_log_id']) ? (int)$context['xlvask_usage_log_id'] : null; + + $fingerprint = sha1(json_encode([ + $definitionKey, + $targetType, + $targetId, + $field, + $messageParams['actual_price'] ?? null, + $messageParams['expected_price'] ?? null, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + $message = $this->automaticMessage($definitionKey, $messageParams); + + return [ + 'id' => 'auto:' . $fingerprint, + 'source' => self::SOURCE_AUTOMATIC, + 'severity' => 'yellow', + 'status' => self::STATUS_ACTIVE, + 'target_type' => $targetType, + 'target_id' => $targetId, + 'field' => $field, + 'customer_number' => $customerNumber, + 'customer_name' => (string)($row['customer_name'] ?? $context['customer_name'] ?? ''), + 'order_id' => $orderId, + 'order_item_id' => $orderItemId, + 'invoice_collection_id' => $invoiceCollectionId, + 'xlvask_usage_log_id' => $xlvaskUsageLogId, + 'definition_key' => $definitionKey, + 'fingerprint' => $fingerprint, + 'reason' => null, + 'message_key' => 'invoice_period.flags.automatic.' . $definitionKey, + 'message_params' => $messageParams, + 'message' => $message, + 'message_parts' => $this->messageParts($definitionKey, $messageParams), + 'context' => $context, + ]; + } + + private function automaticMessage(string $definitionKey, array $params): string + { + $product = (string)($params['product'] ?? 'Item'); + $expectedProduct = (string)($params['expected_product'] ?? 'expected product'); + return match ($definitionKey) { + 'price_mismatch' => "{$product} product price differs from expected.", + 'customer_rule_restrict_addon_services' => "{$product} violates restricted addon services.", + 'customer_rule_restrict_tank_cleaning' => "{$product} violates restricted tank cleaning.", + 'customer_rule_restrict_spot_free' => "{$product} violates restricted Spot Free.", + 'customer_rule_restrict_interior_cleaning' => "{$product} violates restricted interior wash.", + 'customer_rule_exempt_from_administration_fees' => "{$product} is an administration fee for an exempt customer.", + 'customer_rule_only_tank_cleaning' => "{$product} violates the only tank cleaning rule.", + 'customer_rule_requires_reference' => "Order is missing a required reference.", + 'customer_rule_requires_po_number' => "Order is missing a required PO number.", + 'customer_rule_invoice_all_orders_individually' => "Invoice collection contains multiple orders for a customer requiring individual invoices.", + 'quantity_exceeds_product_limit' => "{$product} quantity exceeds the product limit.", + 'multiple_identical_primary_vehicle_items' => "Order contains multiple identical primary vehicle items.", + 'wash_certificate_item_without_certificate' => "Wash certificate item is present without a wash certificate.", + 'wash_certificate_attached_without_item' => "Wash certificate is attached without a wash certificate item.", + 'multiple_fixed_pricing_items_same_month' => "Multiple fixed pricing items exist in the same month.", + 'duplicate_vehicle_subscription_charge_same_month' => "Duplicate vehicle subscription charges exist in the same month.", + 'vehicle_subscription_type_mismatch' => "{$product} does not match the vehicle subscription type {$expectedProduct}.", + 'historical_primary_product_mismatch' => "{$product} differs from the registration number's usual product {$expectedProduct}.", + 'xlvask_missing_order_link' => "XL Vask wash is neither ignored nor linked to an order in the selected period.", + default => "Automatically detected invoice-period issue.", + }; + } + + private function messageParts(string $definitionKey, array $params): array + { + return match ($definitionKey) { + 'price_mismatch' => [ + ['type' => 'order_item', 'text' => (string)($params['product'] ?? 'Item')], + ['type' => 'text', 'text' => ' product price differs from '], + ['type' => 'expected_price', 'text' => 'expected'], + ['type' => 'text', 'text' => '.'], + ], + 'multiple_identical_primary_vehicle_items' => [ + ['type' => 'order', 'text' => 'Order'], + ['type' => 'text', 'text' => ' contains multiple identical primary vehicle items.'], + ], + 'wash_certificate_item_without_certificate' => [ + ['type' => 'order_item', 'text' => 'Wash certificate item'], + ['type' => 'text', 'text' => ' is present without a wash certificate.'], + ], + 'wash_certificate_attached_without_item' => [ + ['type' => 'order', 'text' => 'Wash certificate'], + ['type' => 'text', 'text' => ' is attached without a wash certificate item.'], + ], + 'xlvask_missing_order_link' => [ + ['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'], + ['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'], + ], + default => [], + }; + } + + private function resolveTargetContext(string $targetType, int $targetId, ?string $field): array + { + global $db; + + if ($targetType === 'customer') { + return ['customer_number' => $targetId]; + } + + if ($targetType === 'order' || $targetType === 'order_field') { + $result = $db->query("SELECT id, customer_id, invoice_collection_id, department_id FROM orders WHERE id = {$targetId} LIMIT 1"); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : []; + return [ + 'customer_number' => isset($row['customer_id']) ? (int)$row['customer_id'] : null, + 'order_id' => $targetId, + 'invoice_collection_id' => isset($row['invoice_collection_id']) ? (int)$row['invoice_collection_id'] : null, + 'department_id' => isset($row['department_id']) ? (int)$row['department_id'] : null, + 'field' => $field, + ]; + } + + if ($targetType === 'order_item' || $targetType === 'order_item_field') { + $result = $db->query( + "SELECT oi.id, oi.order_id, o.customer_id, o.invoice_collection_id, o.department_id + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.id = {$targetId} + LIMIT 1" + ); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : []; + return [ + 'customer_number' => isset($row['customer_id']) ? (int)$row['customer_id'] : null, + 'order_id' => isset($row['order_id']) ? (int)$row['order_id'] : null, + 'order_item_id' => $targetId, + 'invoice_collection_id' => isset($row['invoice_collection_id']) ? (int)$row['invoice_collection_id'] : null, + 'department_id' => isset($row['department_id']) ? (int)$row['department_id'] : null, + 'field' => $field, + ]; + } + + if ($targetType === 'collected_order_invoice') { + $result = $db->query("SELECT id, customer_number FROM collected_order_invoices WHERE id = {$targetId} LIMIT 1"); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : []; + return [ + 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, + 'invoice_collection_id' => $targetId, + ]; + } + + if ($targetType === 'xlvask_usage_log') { + $result = $db->query("SELECT id, CustomerId, WashId, RegistrationNumber, StartTime FROM xlvask_usage_logs WHERE id = {$targetId} LIMIT 1"); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : []; + return [ + 'customer_number' => isset($row['CustomerId']) ? (int)$row['CustomerId'] : null, + 'xlvask_usage_log_id' => $targetId, + 'wash_id' => (string)($row['WashId'] ?? ''), + 'registration_number' => (string)($row['RegistrationNumber'] ?? ''), + 'start_time' => (string)($row['StartTime'] ?? ''), + ]; + } + + return []; + } + + private function normalizeField(string $targetType, mixed $field): ?string + { + $field = trim((string)($field ?? '')); + if ($field === '') { + return null; + } + if ($targetType === 'order_field' && !in_array($field, self::ORDER_FIELDS, true)) { + throw new \InvalidArgumentException('Invalid order flag field.'); + } + if ($targetType === 'order_item_field' && !in_array($field, self::ORDER_ITEM_FIELDS, true)) { + throw new \InvalidArgumentException('Invalid order item flag field.'); + } + return $field; + } + + private function statusIndicatorForCustomer(array $customer, array $flags): string + { + $counts = $this->countFlags($flags); + if ($counts['manual'] > 0) { + return 'flag_red'; + } + if ($counts['automatic'] > 0) { + return 'flag_yellow'; + } + if (($customer['draft']['is_action_blocked'] ?? false) === true) { + return 'circle_yellow'; + } + return ($customer['requires_action'] ?? false) ? 'circle_red' : 'circle_green'; + } + + private function countFlags(array $flags): array + { + $manual = 0; + $automatic = 0; + foreach ($flags as $flag) { + if (($flag['status'] ?? self::STATUS_ACTIVE) !== self::STATUS_ACTIVE) { + continue; + } + if (($flag['source'] ?? '') === self::SOURCE_MANUAL) { + $manual++; + } elseif (($flag['source'] ?? '') === self::SOURCE_AUTOMATIC) { + $automatic++; + } + } + return [ + 'manual' => $manual, + 'automatic' => $automatic, + 'total' => $manual + $automatic, + ]; + } + + private function sortFlags(array $a, array $b): int + { + $sourceOrder = [self::SOURCE_MANUAL => 0, self::SOURCE_AUTOMATIC => 1]; + $sourceCompare = ($sourceOrder[$a['source'] ?? ''] ?? 99) <=> ($sourceOrder[$b['source'] ?? ''] ?? 99); + if ($sourceCompare !== 0) { + return $sourceCompare; + } + return strcmp((string)($a['created_at'] ?? $a['fingerprint'] ?? ''), (string)($b['created_at'] ?? $b['fingerprint'] ?? '')); + } + + private function ensureFlagOnlyCustomers(array $types, array $flags): array + { + if (!isset($types['all']) || !is_array($types['all'])) { + $types['all'] = []; + } + + $existing = []; + foreach ($types['all'] as $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + if ($customerNumber > 0) { + $existing[$customerNumber] = true; + } + } + + foreach ($flags as $flag) { + $customerNumber = (int)($flag['customer_number'] ?? 0); + if ($customerNumber < 1 || isset($existing[$customerNumber])) { + continue; + } + $types['all'][] = [ + 'id' => null, + 'customer_number' => $customerNumber, + 'customer_name' => (string)($flag['customer_name'] ?? $this->getCustomerName($customerNumber)), + 'transactions' => [], + 'requires_action' => false, + 'meta' => ['flag_only' => true], + 'queue' => ['has_active_job' => false, 'statuses' => [], 'invoice_collection_ids' => [], 'is_action_blocked' => false], + 'draft' => ['has_valid_draft' => false, 'invoice_collection_ids' => [], 'is_action_blocked' => false], + ]; + $existing[$customerNumber] = true; + } + + return $types; + } + + private function getCustomerName(int $customerNumber): string + { + global $db; + $result = $db->query("SELECT display_name FROM users WHERE customer_number = {$customerNumber} LIMIT 1"); + if ($result && $result->num_rows > 0) { + $row = $result->fetch_assoc(); + $displayName = trim((string)($row['display_name'] ?? '')); + return $displayName !== '' ? $displayName : '#' . $customerNumber; + } + return '#' . $customerNumber; + } + + private function getUserDisplayName(?int $userId): ?string + { + global $db; + if ($userId === null || $userId < 1) { + return null; + } + if (array_key_exists($userId, $this->userDisplayNameCache)) { + return $this->userDisplayNameCache[$userId]; + } + + $result = $db->query("SELECT display_name FROM users WHERE id = {$userId} LIMIT 1"); + if (!$result || $result->num_rows === 0) { + $this->userDisplayNameCache[$userId] = null; + return null; + } + + $row = $result->fetch_assoc(); + $displayName = trim((string)($row['display_name'] ?? '')); + $this->userDisplayNameCache[$userId] = $displayName === '' ? null : $displayName; + return $this->userDisplayNameCache[$userId]; + } + + private function getOrderItemToOrderMap(array $orderIds): array + { + global $db; + $orderIds = array_values(array_filter(array_map('intval', $orderIds))); + if (empty($orderIds)) { + return []; + } + $in = implode(',', $orderIds); + $result = $db->query("SELECT id, order_id FROM order_items WHERE order_id IN ({$in})"); + $map = []; + if ($result) { + while ($row = $result->fetch_assoc()) { + $map[(int)$row['id']] = (int)$row['order_id']; + } + } + return $map; + } + + private function getVehicleSubscriptionTypeMap(array $primaryRows): array + { + global $db; + $pairs = []; + foreach ($primaryRows as $row) { + $customerNumber = (int)$row['customer_number']; + $reg = strtoupper(trim((string)($row['reg_1'] ?? ''))); + if ($customerNumber > 0 && $reg !== '') { + $pairs[$customerNumber . '|' . $reg] = [$customerNumber, $reg]; + } + } + if (empty($pairs)) { + return []; + } + + $customerNumbers = implode(',', array_unique(array_map(static fn($pair): int => (int)$pair[0], $pairs))); + $deletedFilter = $this->columnExists('customer_vehicles', 'deleted_at') + ? "AND cv.deleted_at IS NULL" + : ""; + $result = $db->query( + "SELECT cv.customer_id, UPPER(TRIM(cv.reg)) AS reg, cv.type AS product_id, p.name AS product_name + FROM customer_vehicles cv + LEFT JOIN products p ON p.id = cv.type + WHERE cv.customer_id IN ({$customerNumbers}) + AND cv.wash_subscription = 1 + {$deletedFilter}" + ); + $map = []; + if ($result) { + while ($row = $result->fetch_assoc()) { + $key = (int)$row['customer_id'] . '|' . strtoupper(trim((string)$row['reg'])); + if (isset($pairs[$key])) { + $map[$key] = [ + 'product_id' => (int)$row['product_id'], + 'product_name' => (string)($row['product_name'] ?? ''), + ]; + } + } + } + return $map; + } + + private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers): array + { + global $db; + + $registrations = []; + foreach ($registrationNumbers as $registrationNumber) { + $registrationNumber = preg_replace('/[^A-Z0-9]/', '', strtoupper(trim((string)$registrationNumber))); + $registrationNumber = is_string($registrationNumber) ? $registrationNumber : ''; + if ($registrationNumber !== '') { + $registrations[$registrationNumber] = true; + } + } + if (empty($registrations)) { + return []; + } + + $dateFrom = $db->escape_string($dateFrom); + $historyStart = $db->escape_string(date('Y-m-d H:i:s', strtotime($dateFrom . ' -18 months'))); + $registrationFilter = implode(',', array_map(static function (string $registrationNumber) use ($db): string { + return "'" . $db->escape_string($registrationNumber) . "'"; + }, array_keys($registrations))); + $result = $db->query( + "SELECT UPPER(TRIM(o.reg_1)) AS reg, oi.product_id, p.name AS product_name, COUNT(*) AS usage_count + FROM orders o + JOIN order_items oi ON oi.order_id = o.id + JOIN products p ON p.id = oi.product_id + WHERE o.created_at >= '{$historyStart}' + AND o.created_at < '{$dateFrom}' + AND o.deleted_at IS NULL + AND (oi.deleted_at IS NULL OR oi.deleted_at = '') + AND p.is_wash = 1 + AND COALESCE(oi.related_item_id, 0) = 0 + AND COALESCE(o.reg_1, '') <> '' + AND o.reg_1 IN ({$registrationFilter}) + GROUP BY UPPER(TRIM(o.reg_1)), oi.product_id, p.name + ORDER BY reg, usage_count DESC, oi.product_id ASC" + ); + + $history = []; + $seenCounts = []; + if ($result) { + while ($row = $result->fetch_assoc()) { + $reg = (string)$row['reg']; + $item = [ + 'product_id' => (int)$row['product_id'], + 'product_name' => (string)($row['product_name'] ?? ''), + 'count' => (int)$row['usage_count'], + ]; + + if (!isset($seenCounts[$reg])) { + $seenCounts[$reg] = 1; + if ($item['count'] >= 3) { + $history[$reg] = $item; + } + continue; + } + + if ($seenCounts[$reg] === 1) { + $seenCounts[$reg] = 2; + if (isset($history[$reg]) && $item['count'] >= (int)$history[$reg]['count']) { + unset($history[$reg]); + } + } + } + } + return $history; + } + + private function primaryVehicleProductsMatch( + int $currentProductId, + string $currentProductName, + int $expectedProductId, + string $expectedProductName + ): bool { + if ($expectedProductId > 0 && $currentProductId === $expectedProductId) { + return true; + } + + $currentVehicleType = $this->normalizePrimaryVehicleProductName($currentProductName); + $expectedVehicleType = $this->normalizePrimaryVehicleProductName($expectedProductName); + if ($currentVehicleType === '' || $expectedVehicleType === '') { + return false; + } + if ($currentVehicleType === $expectedVehicleType) { + return true; + } + // Allow a match if one normalized name's tokens are a subset of the other. + // E.g. "Indvendig vask Kassevogn" → "kassevogn" is a subset of + // "Kassevogn/varevogn" → "kassevogn varevogn", meaning the same vehicle type. + $currentTokens = explode(' ', $currentVehicleType); + $expectedTokens = explode(' ', $expectedVehicleType); + if (count($currentTokens) <= count($expectedTokens)) { + return array_diff($currentTokens, $expectedTokens) === []; + } + return array_diff($expectedTokens, $currentTokens) === []; + } + + private function normalizePrimaryVehicleProductName(string $productName): string + { + $normalized = strtolower(strtr($productName, [ + 'Æ' => 'ae', + 'Ø' => 'oe', + 'Å' => 'aa', + 'æ' => 'ae', + 'ø' => 'oe', + 'å' => 'aa', + ])); + $normalized = (string)preg_replace('/[^a-z0-9]+/', ' ', $normalized); + $tokens = array_values(array_filter( + explode(' ', trim($normalized)), + static fn(string $token): bool => $token !== '' + && !in_array($token, [ + 'indvendig', + 'indv', + 'interior', + 'internal', + 'vask', + 'wash', + ], true) + )); + + return implode(' ', $tokens); + } + + private function getOrderItemsForPreview(int $orderId): array + { + global $db; + if ($orderId < 1) { + return []; + } + if (array_key_exists($orderId, $this->orderItemsPreviewCache)) { + return $this->orderItemsPreviewCache[$orderId]; + } + + $result = $db->query( + "SELECT oi.id, oi.product_id, oi.price, oi.quantity, p.name AS product_name + FROM order_items oi + LEFT JOIN products p ON p.id = oi.product_id + WHERE oi.order_id = {$orderId} + AND (oi.deleted_at IS NULL OR oi.deleted_at = '') + ORDER BY oi.related_item_id IS NOT NULL, oi.id" + ); + $rows = $result ? $db->fetch_all($result) : []; + $this->orderItemsPreviewCache[$orderId] = array_map(static function (array $row): array { + return [ + 'id' => (int)($row['id'] ?? 0), + 'product_id' => (int)($row['product_id'] ?? 0), + 'product_name' => (string)($row['product_name'] ?? ''), + 'quantity' => (int)($row['quantity'] ?? 0), + 'price' => (int)($row['price'] ?? 0), + ]; + }, $rows); + + return $this->orderItemsPreviewCache[$orderId]; + } + + private function seedOrderItemsPreviewCacheFromRows(array $rows): void + { + $grouped = []; + foreach ($rows as $row) { + $orderId = (int)($row['order_id'] ?? 0); + if ($orderId < 1) { + continue; + } + $grouped[$orderId] ??= []; + + $orderItemId = (int)($row['order_item_id'] ?? 0); + if ($orderItemId < 1) { + continue; + } + + $grouped[$orderId][] = [ + 'id' => $orderItemId, + 'product_id' => (int)($row['product_id'] ?? 0), + 'product_name' => (string)($row['product_name'] ?? ''), + 'quantity' => (int)($row['item_quantity'] ?? 0), + 'price' => (int)($row['item_price'] ?? 0), + '_related_sort' => (int)($row['related_item_id'] ?? 0) > 0 ? 1 : 0, + ]; + } + + foreach ($grouped as $orderId => $items) { + usort($items, static function (array $a, array $b): int { + return ((int)$a['_related_sort'] <=> (int)$b['_related_sort']) + ?: ((int)$a['id'] <=> (int)$b['id']); + }); + $this->orderItemsPreviewCache[(int)$orderId] = array_map(static function (array $item): array { + unset($item['_related_sort']); + return $item; + }, $items); + } + } + + private function preloadEconomicCustomerDiscounts(array $rows): void + { + $customerUserIds = []; + foreach ($rows as $row) { + if ((int)($row['apply_category_discount'] ?? 0) !== 1) { + continue; + } + + $customerNumber = (int)($row['customer_number'] ?? 0); + $userId = (int)($row['user_id'] ?? 0); + if ($customerNumber < 1 || $userId < 1 || array_key_exists($customerNumber, $this->economicCustomerDiscountCache)) { + continue; + } + + $customerUserIds[$customerNumber] = $userId; + } + + foreach ($customerUserIds as $customerNumber => $userId) { + $discount = $this->getCachedEconomicCustomerDiscount($userId); + if ($discount === null) { + $discount = $this->loadEconomicCustomerDiscount((int)$customerNumber, $userId); + } + + $this->economicCustomerDiscountCache[(int)$customerNumber] = $discount; + } + } + + private function getCachedEconomicCustomerDiscount(int $userId): ?int + { + if ($userId < 1 || !defined('redis')) { + return null; + } + + try { + $cachedDiscount = constant('redis')->get_economic_customer_discount_percentage($userId); + return $cachedDiscount === null ? null : (int)$cachedDiscount; + } catch (Throwable $e) { + return null; + } + } + + private function loadEconomicCustomerDiscount(int $customerNumber, int $userId): int + { + if ($customerNumber < 1 || $userId < 1 || !defined('redis')) { + return 0; + } + + try { + $discount = (int)(new \customers\economicCustomers())->getCustomerDiscountPercentage($customerNumber); + constant('redis')->cache_economic_customer_discount_percentage($userId, $discount); + return $discount; + } catch (Throwable $e) { + return 0; + } + } + + private function calculateExpectedPrice(array $row): int + { + $base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0); + $discount = $this->discountBreakdown($row)['applied_discount_percentage']; + return (int)round($base * (1 - ($discount / 100))); + } + + private function priceBreakdown(array $row, int $expected): array + { + $departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null; + $base = $departmentPrice ?? (int)($row['product_base_price'] ?? 0); + $discount = $this->discountBreakdown($row); + + return [ + 'product_price' => (int)($row['product_base_price'] ?? 0), + 'department_price' => $departmentPrice, + 'effective_base_price' => $base, + 'product_discount_percentage' => $discount['product_discount_percentage'], + 'category_discount_percentage' => $discount['category_discount_percentage'], + 'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'], + 'applied_discount_percentage' => $discount['applied_discount_percentage'], + 'expected_price' => $expected, + ]; + } + + private function discountBreakdown(array $row): array + { + $productDiscount = (int)($row['product_discount_percentage'] ?? 0); + $categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1; + $categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0; + $economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0; + + return [ + 'product_discount_percentage' => $productDiscount, + 'category_discount_percentage' => $categoryDiscount, + 'economic_customer_discount_percentage' => $economicDiscount, + 'applied_discount_percentage' => max($productDiscount, $categoryDiscount, $economicDiscount), + ]; + } + + private function economicCustomerDiscountPercentage(array $row): int + { + $customerNumber = (int)($row['customer_number'] ?? 0); + if ($customerNumber < 1) { + return 0; + } + if (array_key_exists($customerNumber, $this->economicCustomerDiscountCache)) { + return $this->economicCustomerDiscountCache[$customerNumber]; + } + + $discount = 0; + $userId = (int)($row['user_id'] ?? 0); + if ($userId > 0) { + $discount = $this->getCachedEconomicCustomerDiscount($userId) ?? 0; + } + + $this->economicCustomerDiscountCache[$customerNumber] = $discount; + return $discount; + } + + private function orderContext(array $row): array + { + return [ + 'customer_number' => (int)($row['customer_number'] ?? 0), + 'customer_name' => (string)($row['customer_name'] ?? ''), + 'order_id' => (int)($row['order_id'] ?? 0), + 'invoice_collection_id' => (int)($row['invoice_collection_id'] ?? 0) ?: null, + 'department_id' => (int)($row['department_id'] ?? 0) ?: null, + 'reg_1' => (string)($row['reg_1'] ?? ''), + ]; + } + + private function orderItemContext(array $row): array + { + return $this->orderContext($row) + [ + 'order_item_id' => (int)($row['order_item_id'] ?? 0), + 'product_id' => (int)($row['product_id'] ?? 0), + 'product_name' => $this->productLabel($row), + ]; + } + + private function invoiceCollectionContext(array $row): array + { + return [ + 'customer_number' => (int)($row['customer_number'] ?? 0), + 'customer_name' => (string)($row['customer_name'] ?? ''), + 'invoice_collection_id' => (int)($row['invoice_collection_id'] ?? 0), + ]; + } + + private function productLabel(array $row): string + { + $name = trim((string)($row['product_name'] ?? '')); + return $name !== '' ? $name : 'Item #' . (int)($row['product_id'] ?? 0); + } + + private function hasAttribute(array $attributes, int $customerNumber, string $attribute): bool + { + return isset($attributes[$customerNumber][$attribute]); + } + + private function rowMatchesProductTerms(array $row, array $terms): bool + { + $haystack = strtolower(trim( + (string)($row['product_name'] ?? '') . ' ' . + (string)($row['category_name'] ?? '') + )); + foreach ($terms as $term) { + if ($term !== '' && str_contains($haystack, strtolower($term))) { + return true; + } + } + return false; + } + + private function rowIsTankCleaningProduct(array $row): bool + { + return (int)($row['product_category'] ?? 0) === 5 + || $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']); + } + + private function isIncludedOrderItem(array $row): bool + { + $value = $row['item_include_in_invoice'] ?? 1; + return $value === null || $value === '' || (int)$value === 1; + } + + private function isPrimaryVehicleItem(array $row): bool + { + return (int)($row['order_item_id'] ?? 0) > 0 + && (int)($row['is_wash'] ?? 0) === 1 + && (int)($row['related_item_id'] ?? 0) === 0; + } + + private function isWashCertificateProduct(array $row): bool + { + return (int)($row['product_id'] ?? 0) === self::WASH_CERTIFICATE_PRODUCT_ID + || $this->rowMatchesProductTerms($row, ['wash certificate', 'vaskecertifikat']); + } + + private function rowHasWashCertificateAttachment(array $row): bool + { + return (int)($row['has_wash_certificate_attachment'] ?? 0) === 1; + } + + private function dedupeAutomaticFlags(array $flags): array + { + $deduped = []; + foreach ($flags as $flag) { + $deduped[(string)$flag['fingerprint']] = $flag; + } + return array_values($deduped); + } + + private function customerFilterSql(string $column, ?array $onlyCustomerNumbers): string + { + if ($onlyCustomerNumbers === null) { + return ''; + } + $numbers = array_values(array_filter(array_map('intval', $onlyCustomerNumbers), static fn(int $value): bool => $value > 0)); + if (empty($numbers)) { + return ' AND 1=0'; + } + return ' AND ' . $column . ' IN (' . implode(',', array_unique($numbers)) . ')'; + } + + private function nullableIntSql(mixed $value): string + { + if ($value === null || $value === '') { + return 'NULL'; + } + return (string)(int)$value; + } + + private function nullableStringSql(?string $value): string + { + global $db; + if ($value === null || trim($value) === '') { + return 'NULL'; + } + return "'" . $db->escape_string($value) . "'"; + } + + private function jsonSql(array $value): string + { + global $db; + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + return 'NULL'; + } + return "'" . $db->escape_string($json) . "'"; + } + + private function tableExists(string $table): bool + { + global $db; + $table = str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $table); + $result = $db->query("SHOW TABLES LIKE '{$table}'"); + return $result !== false && is_object($result) && property_exists($result, 'num_rows') && (int)$result->num_rows > 0; + } + + private function columnExists(string $table, string $column): bool + { + global $db; + $table = str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $table); + $column = str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $column); + $result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"); + return $result !== false && is_object($result) && property_exists($result, 'num_rows') && (int)$result->num_rows > 0; + } +} diff --git a/services/nginx/app/classes/motorapi.php b/services/nginx/app/classes/motorapi.php index 7abca203..6c96f7bf 100644 --- a/services/nginx/app/classes/motorapi.php +++ b/services/nginx/app/classes/motorapi.php @@ -155,7 +155,7 @@ class motorapi implements motorapi_i // Get the cached result from the log/local database/cache $motorapi_lookups = new motorapi_lookups_o(); // Add the cached value to the meta - $response->add_meta('cached', true); + self::addCachedMetaIfPossible($response); $cleaned_result = self::cleanJSON($motorapi_lookups->getCachedResult($licensePlate)->result->value()); $object = json_decode($cleaned_result); if ($object === null) { @@ -165,6 +165,13 @@ class motorapi implements motorapi_i return json_decode($cleaned_result); } + public static function addCachedMetaIfPossible(mixed $response): void + { + if (is_object($response) && method_exists($response, 'add_meta')) { + $response->add_meta('cached', true); + } + } + /** * @inheritDoc * @throws Exception If the module is not enabled, the license plate is invalid, the daily limit is exceeded, or the secret key is invalid @@ -385,4 +392,4 @@ class motorapi implements motorapi_i $motorapi_lookups = new motorapi_lookups_o(); $motorapi_lookups->add($licensePlate, json_encode($response), $endpoint); } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/openai.php b/services/nginx/app/classes/openai.php index a6f24800..5fdc9696 100644 --- a/services/nginx/app/classes/openai.php +++ b/services/nginx/app/classes/openai.php @@ -34,11 +34,58 @@ class openai implements openai_i */ public function requireModuleEnabled(): void { - if (!(bool)$this->config->enabled->getVariableValue()) { + if (!$this->config->enabled->isTrue()) { throw new Exception('OpenAI module is not enabled.'); } } + /** + * Send a structured JSON text task to the OpenAI Responses API. + * + * @throws Exception + */ + public function jsonTask(string $schemaName, string $prompt, array $payload, array $schema, float $temperature = 0.1): array + { + $this->requireModuleEnabled(); + + $data = [ + 'model' => $this->model, + 'input' => [ + [ + 'role' => 'user', + 'content' => [ + [ + 'type' => 'input_text', + 'text' => $prompt . "\n\nData:\n" . json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ], + ], + ], + ], + 'text' => [ + 'format' => [ + 'type' => 'json_schema', + 'name' => $schemaName, + 'schema' => $schema, + 'strict' => true, + ], + ], + 'temperature' => $temperature, + ]; + + $response = $this->sendRequest($data); + $output = $response['output'][0]['content'][0]['text'] ?? null; + if (!is_string($output) || $output === '') { + throw new Exception('Invalid response format from OpenAI API. (Missing text field)'); + } + + $decoded = json_decode($output, true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) { + throw new Exception('Error parsing JSON response: ' . json_last_error_msg()); + } + + return $decoded; + } + protected function getLPRSchema(): array { return [ @@ -248,4 +295,4 @@ class openai implements openai_i //print_r($responseData); return $responseData; } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/order_reference_suggestions_service.php b/services/nginx/app/classes/order_reference_suggestions_service.php new file mode 100644 index 00000000..c0daf1a9 --- /dev/null +++ b/services/nginx/app/classes/order_reference_suggestions_service.php @@ -0,0 +1,517 @@ + + */ + private array $columnExistsCache = []; + + /** + * @param array{ + * search?: mixed, + * department_id?: mixed, + * customer_id?: mixed, + * reg_1?: mixed, + * reg_2?: mixed, + * reg_3?: mixed, + * limit?: mixed + * } $criteria + * @return array> + */ + public function suggest(array $criteria): array + { + $departmentId = $this->toPositiveInt($criteria['department_id'] ?? null); + if ($departmentId === null) { + return []; + } + + $search = $this->normalizeText($criteria['search'] ?? ''); + $customerId = $this->toPositiveInt($criteria['customer_id'] ?? null); + $plates = $this->normalizePlates([ + $criteria['reg_1'] ?? '', + $criteria['reg_2'] ?? '', + $criteria['reg_3'] ?? '', + ]); + $limit = $this->clampLimit($criteria['limit'] ?? self::DEFAULT_LIMIT); + + $rows = [ + ...$this->fetchBookingRows($departmentId, $search), + ...$this->fetchOrderRows($departmentId, $search), + ...$this->fetchVehicleRows($customerId, $plates, $search), + ]; + + $suggestions = $this->aggregateRows($rows, $search, $customerId, $plates); + usort($suggestions, [$this, 'sortSuggestions']); + + return array_slice($suggestions, 0, $limit); + } + + /** + * @return array> + */ + private function fetchBookingRows(int $departmentId, string $search): array + { + $where = [ + 'department = :department_id', + 'reference IS NOT NULL', + "TRIM(reference) <> ''", + ]; + if ($this->tableHasColumn('order_bookings', 'deleted_at')) { + array_unshift($where, 'deleted_at IS NULL'); + } + $params = ['department_id' => $departmentId]; + + if ($search !== '') { + $where[] = 'LOWER(reference) LIKE :search'; + $params['search'] = '%' . $this->lower($search) . '%'; + } + + $sql = "SELECT + 'booking' AS source, + id AS origin_id, + TRIM(reference) AS reference, + datetime AS source_created_at, + datetime AS used_at, + customer_number AS customer_id, + department AS department_id, + reg_1, + reg_2, + reg_3 + FROM order_bookings + WHERE " . implode(' AND ', $where) . " + ORDER BY datetime DESC, id DESC + LIMIT :source_limit"; + + return $this->fetchRows($sql, $params); + } + + /** + * @return array> + */ + private function fetchOrderRows(int $departmentId, string $search): array + { + $where = [ + 'department_id = :department_id', + 'reference IS NOT NULL', + "TRIM(reference) <> ''", + ]; + if ($this->tableHasColumn('orders', 'deleted_at')) { + array_unshift($where, 'deleted_at IS NULL'); + } + $params = ['department_id' => $departmentId]; + + if ($search !== '') { + $where[] = 'LOWER(reference) LIKE :search'; + $params['search'] = '%' . $this->lower($search) . '%'; + } + + $sql = "SELECT + 'order' AS source, + id AS origin_id, + TRIM(reference) AS reference, + created_at AS source_created_at, + created_at AS used_at, + customer_id, + department_id, + reg_1, + reg_2, + reg_3 + FROM orders + WHERE " . implode(' AND ', $where) . " + ORDER BY created_at DESC, id DESC + LIMIT :source_limit"; + + return $this->fetchRows($sql, $params); + } + + /** + * @param array $plates + * @return array> + */ + private function fetchVehicleRows(?int $customerId, array $plates, string $search): array + { + $contextWhere = []; + $params = []; + + if ($customerId !== null) { + $contextWhere[] = 'customer_id = :customer_id'; + $params['customer_id'] = $customerId; + } + + foreach ($plates as $index => $plate) { + $key = 'plate_' . $index; + $contextWhere[] = "UPPER(REPLACE(reg, ' ', '')) = :$key"; + $params[$key] = $plate; + } + + if ($contextWhere === []) { + return []; + } + + $where = [ + 'reference IS NOT NULL', + "TRIM(reference) <> ''", + '(' . implode(' OR ', $contextWhere) . ')', + ]; + if ($this->tableHasColumn('customer_vehicles', 'deleted_at')) { + array_unshift($where, 'deleted_at IS NULL'); + } + + if ($search !== '') { + $where[] = 'LOWER(reference) LIKE :search'; + $params['search'] = '%' . $this->lower($search) . '%'; + } + + $sql = "SELECT + 'vehicle' AS source, + id AS origin_id, + TRIM(reference) AS reference, + created_at AS source_created_at, + created_at AS used_at, + customer_id, + NULL AS department_id, + reg AS reg_1, + '' AS reg_2, + '' AS reg_3 + FROM customer_vehicles + WHERE " . implode(' AND ', $where) . " + ORDER BY created_at DESC, id DESC + LIMIT :source_limit"; + + return $this->fetchRows($sql, $params); + } + + /** + * @param array $params + * @return array> + */ + private function fetchRows(string $sql, array $params): array + { + $pdo = db::getPDO(); + $statement = $pdo->prepare($sql); + + foreach ($params as $key => $value) { + $statement->bindValue(':' . $key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR); + } + $statement->bindValue(':source_limit', self::MAX_SOURCE_ROWS, PDO::PARAM_INT); + $statement->execute(); + + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + return is_array($rows) ? $rows : []; + } + + private function tableHasColumn(string $table, string $column): bool + { + $cacheKey = $table . '.' . $column; + if (array_key_exists($cacheKey, $this->columnExistsCache)) { + return $this->columnExistsCache[$cacheKey]; + } + + $pdo = db::getPDO(); + $statement = $pdo->prepare( + 'SELECT COUNT(*) AS total + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = :table_name + AND COLUMN_NAME = :column_name' + ); + $statement->bindValue(':table_name', $table, PDO::PARAM_STR); + $statement->bindValue(':column_name', $column, PDO::PARAM_STR); + $statement->execute(); + + $this->columnExistsCache[$cacheKey] = ((int)$statement->fetchColumn()) > 0; + return $this->columnExistsCache[$cacheKey]; + } + + /** + * @param array> $rows + * @param array $plates + * @return array> + */ + private function aggregateRows(array $rows, string $search, ?int $customerId, array $plates): array + { + $groups = []; + + foreach ($rows as $row) { + $reference = $this->normalizeText($row['reference'] ?? ''); + if ($reference === '') { + continue; + } + + $key = $this->lower($reference); + if (!isset($groups[$key])) { + $groups[$key] = [ + 'reference' => $reference, + 'rows' => [], + 'usage_count' => 0, + 'last_used_at' => null, + 'context_boost' => 0, + 'section' => 'other', + ]; + } + + $section = $this->contextSection($row, $customerId, $plates); + $groups[$key]['usage_count']++; + $groups[$key]['rows'][] = $row; + $groups[$key]['last_used_at'] = $this->maxDate( + $groups[$key]['last_used_at'], + $this->normalizeDate($row['used_at'] ?? null) + ); + $groups[$key]['context_boost'] = max( + $groups[$key]['context_boost'], + $this->contextBoost($row, $customerId, $plates) + ); + $groups[$key]['section'] = $this->bestSection( + (string)$groups[$key]['section'], + $section + ); + } + + $suggestions = []; + foreach ($groups as $group) { + $bestRow = $this->bestOriginRow($group['rows']); + if ($bestRow === null) { + continue; + } + + $source = (string)($bestRow['source'] ?? 'order'); + $usageCount = (int)$group['usage_count']; + $score = $this->matchScore((string)$group['reference'], $search) + + (int)$group['context_boost'] + + $this->sectionScore((string)$group['section']) + + $this->sourceScore($source) + + min($usageCount, 20) * 5; + + $suggestions[] = [ + 'source' => $source, + 'section' => (string)$group['section'], + 'reference' => (string)$group['reference'], + 'source_created_at' => $this->normalizeDate($bestRow['source_created_at'] ?? null), + 'last_used_at' => $group['last_used_at'], + 'usage_count' => $usageCount, + 'origin_id' => (int)($bestRow['origin_id'] ?? 0), + 'score' => $score, + ]; + } + + return $suggestions; + } + + /** + * @param array> $rows + */ + private function bestOriginRow(array $rows): ?array + { + usort($rows, function (array $left, array $right): int { + $sourceCompare = $this->sourceScore((string)($right['source'] ?? '')) + <=> $this->sourceScore((string)($left['source'] ?? '')); + if ($sourceCompare !== 0) { + return $sourceCompare; + } + + $dateCompare = strcmp( + (string)$this->normalizeDate($right['source_created_at'] ?? null), + (string)$this->normalizeDate($left['source_created_at'] ?? null) + ); + if ($dateCompare !== 0) { + return $dateCompare; + } + + return ((int)($right['origin_id'] ?? 0)) <=> ((int)($left['origin_id'] ?? 0)); + }); + + return $rows[0] ?? null; + } + + private function sortSuggestions(array $left, array $right): int + { + $scoreCompare = ((int)($right['score'] ?? 0)) <=> ((int)($left['score'] ?? 0)); + if ($scoreCompare !== 0) { + return $scoreCompare; + } + + $usageCompare = ((int)($right['usage_count'] ?? 0)) <=> ((int)($left['usage_count'] ?? 0)); + if ($usageCompare !== 0) { + return $usageCompare; + } + + $sectionCompare = $this->sectionScore((string)($right['section'] ?? '')) + <=> $this->sectionScore((string)($left['section'] ?? '')); + if ($sectionCompare !== 0) { + return $sectionCompare; + } + + $dateCompare = strcmp((string)($right['last_used_at'] ?? ''), (string)($left['last_used_at'] ?? '')); + if ($dateCompare !== 0) { + return $dateCompare; + } + + $referenceCompare = strcmp((string)($left['reference'] ?? ''), (string)($right['reference'] ?? '')); + if ($referenceCompare !== 0) { + return $referenceCompare; + } + + return $this->sourceScore((string)($right['source'] ?? '')) <=> $this->sourceScore((string)($left['source'] ?? '')); + } + + /** + * @param array $plates + */ + private function contextBoost(array $row, ?int $customerId, array $plates): int + { + $score = 0; + if ($customerId !== null && (int)($row['customer_id'] ?? 0) === $customerId) { + $score += 80; + } + + if ($this->rowMatchesAnyPlate($row, $plates)) { + $score += 90; + } + + return $score; + } + + /** + * @param array $plates + */ + private function contextSection(array $row, ?int $customerId, array $plates): string + { + if ($this->rowMatchesAnyPlate($row, $plates)) { + return 'this_vehicle'; + } + + if ($customerId !== null && (int)($row['customer_id'] ?? 0) === $customerId) { + return 'other_customer_vehicle'; + } + + return 'other'; + } + + /** + * @param array $plates + */ + private function rowMatchesAnyPlate(array $row, array $plates): bool + { + $rowPlates = $this->normalizePlates([ + $row['reg_1'] ?? '', + $row['reg_2'] ?? '', + $row['reg_3'] ?? '', + ]); + + return $plates !== [] && array_intersect($plates, $rowPlates) !== []; + } + + private function matchScore(string $reference, string $search): int + { + if ($search === '') { + return 0; + } + + $referenceKey = $this->lower($reference); + $searchKey = $this->lower($search); + + if ($referenceKey === $searchKey) { + return 1000; + } + + if (str_starts_with($referenceKey, $searchKey)) { + return 600; + } + + if (str_contains($referenceKey, $searchKey)) { + return 300; + } + + return 0; + } + + private function sourceScore(string $source): int + { + return match ($source) { + 'booking' => 30, + 'order' => 20, + 'vehicle' => 10, + default => 0, + }; + } + + private function sectionScore(string $section): int + { + return match ($section) { + 'this_vehicle' => 40, + 'other_customer_vehicle' => 20, + default => 0, + }; + } + + private function bestSection(string $left, string $right): string + { + return $this->sectionScore($right) > $this->sectionScore($left) ? $right : $left; + } + + private function clampLimit(mixed $value): int + { + $limit = $this->toPositiveInt($value) ?? self::DEFAULT_LIMIT; + return max(1, min($limit, self::MAX_LIMIT)); + } + + private function toPositiveInt(mixed $value): ?int + { + $parsed = filter_var($value, FILTER_VALIDATE_INT); + return is_int($parsed) && $parsed > 0 ? $parsed : null; + } + + private function normalizeText(mixed $value): string + { + return trim((string)($value ?? '')); + } + + private function lower(string $value): string + { + return function_exists('mb_strtolower') ? mb_strtolower($value) : strtolower($value); + } + + /** + * @param array $values + * @return array + */ + private function normalizePlates(array $values): array + { + $plates = []; + foreach ($values as $value) { + $plate = strtoupper(preg_replace('/\s+/', '', (string)($value ?? ''))); + if ($plate !== '') { + $plates[] = $plate; + } + } + + return array_values(array_unique($plates)); + } + + private function normalizeDate(mixed $value): ?string + { + $date = trim((string)($value ?? '')); + return $date === '' || $date === '0000-00-00 00:00:00' ? null : $date; + } + + private function maxDate(?string $left, ?string $right): ?string + { + if ($left === null) { + return $right; + } + if ($right === null) { + return $left; + } + + return strcmp($right, $left) > 0 ? $right : $left; + } +} diff --git a/services/nginx/app/classes/orders_schema_bootstrap.php b/services/nginx/app/classes/orders_schema_bootstrap.php index b572c73d..d05fff0d 100644 --- a/services/nginx/app/classes/orders_schema_bootstrap.php +++ b/services/nginx/app/classes/orders_schema_bootstrap.php @@ -41,9 +41,39 @@ 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'); + self::ensureIndex($db, 'customer_attributes', 'idx_customer_attributes_attribute_user', 'attribute, user_id'); + 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); @@ -69,6 +99,46 @@ class orders_schema_bootstrap return (int)$result->num_rows > 0; } + private static function ensureIndex(object $db, string $table, string $index, string $columns): void + { + if ( + !self::tableExists($db, $table) + || self::indexExists($db, $table, $index) + || !self::columnsExist($db, $table, $columns) + ) { + return; + } + + $table = self::escapeIdentifier($table); + $index = self::escapeIdentifier($index); + $db->query("ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})"); + } + + private static function columnsExist(object $db, string $table, string $columns): bool + { + foreach (explode(',', $columns) as $column) { + $column = trim($column, " \t\n\r\0\x0B`"); + if ($column === '' || !self::columnExists($db, $table, $column)) { + return false; + } + } + + return true; + } + + private static function indexExists(object $db, string $table, string $index): bool + { + $table = self::escapeIdentifier($table); + $index = self::escapeIdentifier($index); + $result = $db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + private static function escapeIdentifier(string $value): string { return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value); diff --git a/services/nginx/app/classes/products_schema_bootstrap.php b/services/nginx/app/classes/products_schema_bootstrap.php new file mode 100644 index 00000000..3963f1d9 --- /dev/null +++ b/services/nginx/app/classes/products_schema_bootstrap.php @@ -0,0 +1,68 @@ +query( + "ALTER TABLE products + ADD COLUMN max_quantity_per_order INT NULL DEFAULT NULL + AFTER order_priority" + ); + } + + self::$initialized = true; + } + + private static function tableExists(object $db, string $table): bool + { + $table = self::escapeIdentifier($table); + $result = $db->query("SHOW TABLES LIKE '{$table}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function columnExists(object $db, string $table, string $column): bool + { + $table = self::escapeIdentifier($table); + $column = self::escapeIdentifier($column); + $result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function escapeIdentifier(string $value): string + { + return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value); + } +} diff --git a/services/nginx/app/classes/redis.php b/services/nginx/app/classes/redis.php index cc826ec4..76c0c96a 100644 --- a/services/nginx/app/classes/redis.php +++ b/services/nginx/app/classes/redis.php @@ -364,6 +364,178 @@ class redis implements redis_i return $this; } + /** + * @inheritDoc + */ + public function cache_invoice_period_manual_flags(array $flags): self + { + $this->set_array('invoice_period_manual_flags', $flags); + return $this; + } + + /** + * @inheritDoc + */ + public function get_invoice_period_manual_flags(): array|null + { + return $this->get_array('invoice_period_manual_flags'); + } + + /** + * @inheritDoc + */ + public function clear_invoice_period_manual_flags(): self + { + $this->delete('invoice_period_manual_flags'); + return $this; + } + + private function invoicePeriodCacheKey(string $prefix, string $dateFrom, string $dateTo): string + { + return $prefix . ':' . $dateFrom . ':' . $dateTo; + } + + private function workfeedEmployeeNameCacheKey(string $employeeId): string + { + return 'workfeed_employee_name:' . rawurlencode($employeeId); + } + + /** + * @inheritDoc + */ + public function cache_invoice_period_automatic_flags(string $dateFrom, string $dateTo, array $flags): self + { + $this->set_array($this->invoicePeriodCacheKey('invoice_period_automatic_flags', $dateFrom, $dateTo), $flags); + return $this; + } + + /** + * @inheritDoc + */ + public function get_invoice_period_automatic_flags(string $dateFrom, string $dateTo): array|null + { + return $this->get_array($this->invoicePeriodCacheKey('invoice_period_automatic_flags', $dateFrom, $dateTo)); + } + + /** + * @inheritDoc + */ + public function clear_invoice_period_automatic_flags(string $dateFrom, string $dateTo): self + { + $this->delete($this->invoicePeriodCacheKey('invoice_period_automatic_flags', $dateFrom, $dateTo)); + return $this; + } + + /** + * @inheritDoc + */ + public function cache_invoice_period_order_item_rows(string $dateFrom, string $dateTo, array $rows): self + { + $this->set_array($this->invoicePeriodCacheKey('invoice_period_order_item_rows', $dateFrom, $dateTo), $rows); + return $this; + } + + /** + * @inheritDoc + */ + public function get_invoice_period_order_item_rows(string $dateFrom, string $dateTo): array|null + { + return $this->get_array($this->invoicePeriodCacheKey('invoice_period_order_item_rows', $dateFrom, $dateTo)); + } + + /** + * @inheritDoc + */ + public function clear_invoice_period_order_item_rows(string $dateFrom, string $dateTo): self + { + $this->delete($this->invoicePeriodCacheKey('invoice_period_order_item_rows', $dateFrom, $dateTo)); + return $this; + } + + /** + * @inheritDoc + */ + public function cache_workfeed_employee_name(string $employeeId, string $employeeName, int $ttl = 86400): self + { + $normalizedEmployeeId = trim($employeeId); + if ($normalizedEmployeeId === '') { + return $this; + } + + $normalizedEmployeeName = trim($employeeName); + if ($normalizedEmployeeName === '') { + return $this; + } + + $key = $this->workfeedEmployeeNameCacheKey($normalizedEmployeeId); + $this->set($key, $normalizedEmployeeName); + $this->expire($key, $ttl); + + return $this; + } + + /** + * @inheritDoc + */ + public function get_workfeed_employee_name(string $employeeId): string|null + { + $normalizedEmployeeId = trim($employeeId); + if ($normalizedEmployeeId === '') { + return null; + } + + $value = $this->get($this->workfeedEmployeeNameCacheKey($normalizedEmployeeId)); + if ($value === null) { + return null; + } + + $normalized = trim((string)$value); + return $normalized !== '' ? $normalized : null; + } + + /** + * @inheritDoc + */ + public function clear_workfeed_employee_name(string $employeeId): self + { + $normalizedEmployeeId = trim($employeeId); + if ($normalizedEmployeeId === '') { + return $this; + } + + $this->delete($this->workfeedEmployeeNameCacheKey($normalizedEmployeeId)); + return $this; + } + + /** + * @inheritDoc + */ + public function enqueue_invoice_period_warming(string $dateFrom, string $dateTo): self + { + $this->get_client()->sadd('invoice_period_warming_queue', [$dateFrom . '|' . $dateTo]); + return $this; + } + + /** + * @inheritDoc + */ + public function consume_invoice_period_warming_queue(): array + { + $client = $this->get_client(); + $members = $client->smembers('invoice_period_warming_queue'); + if (!empty($members)) { + $client->del('invoice_period_warming_queue'); + } + $periods = []; + foreach ($members as $member) { + $parts = explode('|', (string)$member, 2); + if (count($parts) === 2 && $parts[0] !== '' && $parts[1] !== '') { + $periods[] = ['dateFrom' => $parts[0], 'dateTo' => $parts[1]]; + } + } + return $periods; + } + /** * @inheritDoc */ @@ -471,6 +643,10 @@ class redis implements redis_i public function mget(array $array_map): array { + if (empty($array_map)) { + return []; + } + // Get multiple keys from Redis return $this->redis->mget($array_map); } diff --git a/services/nginx/app/classes/release_manager.php b/services/nginx/app/classes/release_manager.php new file mode 100644 index 00000000..6e59bb57 --- /dev/null +++ b/services/nginx/app/classes/release_manager.php @@ -0,0 +1,10802 @@ + 'master', + 'master' => 'master', + 'beta' => 'beta', + 'canary' => 'canary', + 'internal' => 'internal', + ]; + private const RELEASE_ROUTE_CHANNELS = [ + 'master' => 'stable', + 'beta' => 'beta', + 'canary' => 'canary', + 'internal' => 'internal', + ]; + private const SERVICE_SET_MODES = ['attach_existing', 'clone_existing', 'fresh_empty', 'isolated_stack']; + private const STACK_DATA_KINDS = ['database', 'redis', 'minio']; + private const PRODUCTION_DATA_POLICY = 'production_shared'; + private const BETA_PRODUCTION_DATA_SOURCE_CHANNELS = ['stable', 'master', 'production', 'prod']; + private const PRODUCTION_SERVICE_POLICY = 'production_shared'; + private const PRODUCTION_SERVICE_CHANNELS = ['beta']; + private const RELEASE_STATUS_SERVICES = ['frontend', 'api', 'database', 'redis', 'minio']; + private const DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS = ['stable', 'production', 'prod']; + private const DEFAULT_COOLIFY_APPLICATION_PORT = '80'; + private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api'; + private const RELEASE_API_RUNTIME_ENV_KEYS = [ + 'USE_ENV', + 'DEBUG', + 'ENCRYPTION_KEY', + 'CORS', + 'CONFIG_TIMEZONE', + 'CONFIG_DB_TARGET', + 'CONFIG_DB_HOST', + 'CONFIG_DB_USER', + 'CONFIG_DB_PASSWORD', + 'CONFIG_DB_DATABASE', + 'CONFIG_DB_PORT', + 'CONFIG_DB_SSL_MODE', + 'CONFIG_DB_DEBUG_HOST', + 'CONFIG_DB_DEBUG_USER', + 'CONFIG_DB_DEBUG_PASSWORD', + 'CONFIG_DB_DEBUG_DATABASE', + 'CONFIG_DB_DEBUG_PORT', + 'CONFIG_DB_DEBUG_SSL_MODE', + 'REDIS_CONFIG_HOST', + 'REDIS_CONFIG_USER', + 'REDIS_CONFIG_PASSWORD', + 'REDIS_CONFIG_DATABASE', + 'REDIS_CONFIG_PORT', + 'REDIS_CONFIG_DEBUG_HOST', + 'REDIS_CONFIG_DEBUG_USER', + 'REDIS_CONFIG_DEBUG_PASSWORD', + 'REDIS_CONFIG_DEBUG_DATABASE', + 'REDIS_CONFIG_DEBUG_PORT', + 'ECONOMIC_API_APP_ACCESS_GRANT', + 'ECONOMIC_API_APP_ACCESS_GRANT2', + 'ECONOMIC_API_APP_SECRET_TOKEN', + 'WORDPRESS_STATIC_TOKEN', + 'EMAIL_WASH_CERTIFICATE_TOKEN', + 'WORDPRESS_API_URL', + 'MINIO_ENDPOINT', + 'MINIO_ACCESS_KEY', + 'MINIO_SECRET_KEY', + 'SLACK_DEFAULT_WEBHOOK', + 'API_COMMIT_SHA', + 'COMMIT_SHA', + 'GITHUB_SHA', + 'RELEASE_COMMIT_SHA', + ]; + private const RELEASE_API_RUNTIME_ENV_PREFIXES = [ + 'EDGE_', + 'RELEASE_MANAGER_', + 'COOLIFY_', + 'HETZNER_', + 'OPENAI_', + 'STRIPE_', + 'FXRATES_', + 'WEATHER_', + 'MOTOR_', + 'BIRD_', + 'OCR_', + 'LICENSE_', + 'VIRK_', + 'LIMBLE_', + 'ENTRA_', + 'REQUEST_QUEUE_', + 'WORKFEED_', + ]; + private const SUBJECT_TYPES = ['user', 'subuser', 'customer']; + private const CAPTURE_LEVELS = ['metadata', 'full_redacted', 'full']; + private const MODULE_KEYS = [ + 'economic', + 'reCAPTCHA', + 'email', + 'backups', + 'motorapi', + 'stripe', + 'fxratesapi', + 'weatherapi', + 'workfeed', + 'gatewayapi', + 'xlvask', + 'entra', + 'limble', + 'ocrspace', + 'openai', + 'licenseplaterecognizer', + 'virkdata', + 'shelly', + 'coolify', + 'failover', + 'edgegateway', + 'selfserve', + 'bird', + 'auth', + 'worker', + 'requestqueue', + 'moduleactionlogs', + 'releasemanager', + ]; + + private bool $schemaEnsured = false; + private array $inProcessPassedReleaseGates = []; + + public static function initializeRequestContext(): array + { + $traceId = self::safeIdentifier( + self::requestHeaderValue('X-Release-Trace') ?: (string)($_GET['release_trace'] ?? ''), + 64 + ); + if ($traceId === '') { + $traceId = bin2hex(random_bytes(16)); + } + + $requestedChannel = self::safeSlug((string)( + self::requestHeaderValue('X-Release-Channel') + ?: ($_GET['release_channel'] ?? $_GET['channel_slug'] ?? '') + )); + $frontendVersion = self::safeIdentifier( + (string)(self::requestHeaderValue('X-Frontend-Version') ?: ($_GET['frontend_version'] ?? '')), + 128 + ); + + $context = [ + 'trace_id' => $traceId, + 'requested_channel' => $requestedChannel, + 'frontend_version' => $frontendVersion, + 'backend_version' => self::backendVersion(), + 'request_started_at' => date('c'), + 'original_request_uri' => (string)($_SERVER['REQUEST_URI'] ?? ''), + 'normalized_request_uri' => '', + 'ingress_prefix_stripped' => false, + ]; + + $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context; + return $context; + } + + public static function normalizeReleaseApiIngressPath(array $enabledChannelSlugs): array + { + $requestUri = (string)($_SERVER['REQUEST_URI'] ?? ''); + $parts = parse_url($requestUri); + $path = is_array($parts) ? (string)($parts['path'] ?? '') : ''; + if ($path === '') { + return []; + } + + if (preg_match('#^/([A-Za-z0-9_-]{1,64})/api(?:/|$)(.*)$#', $path, $matches) !== 1) { + return []; + } + + $routeSlug = self::safeSlug((string)$matches[1]); + $channelSlug = self::channelSlugForRoute($routeSlug); + $enabled = array_flip(array_values(array_filter(array_map( + static fn(mixed $value): string => self::safeSlug((string)$value), + $enabledChannelSlugs + )))); + if ($routeSlug === '' || $channelSlug === '' || !isset($enabled[$channelSlug])) { + return []; + } + + $suffix = (string)($matches[2] ?? ''); + $normalizedPath = '/' . ltrim($suffix, '/'); + if ($normalizedPath === '/') { + $normalizedPath = '/'; + } + + $query = is_array($parts) && isset($parts['query']) && $parts['query'] !== '' + ? '?' . (string)$parts['query'] + : ''; + $normalizedUri = $normalizedPath . $query; + $_SERVER['REQUEST_URI'] = $normalizedUri; + $_SERVER['PATH_INFO'] = $normalizedPath; + $_GET['release_channel'] = $channelSlug; + + $context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null) + ? $GLOBALS['RELEASE_REQUEST_CONTEXT'] + : self::initializeRequestContext(); + $context['original_request_uri'] = $context['original_request_uri'] ?: $requestUri; + $context['normalized_request_uri'] = $normalizedUri; + $context['requested_channel'] = $channelSlug; + $context['release_route_slug'] = $routeSlug; + $context['ingress_prefix_stripped'] = true; + $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context; + + return [ + 'channel_slug' => $channelSlug, + 'route_slug' => $routeSlug, + 'original_request_uri' => $requestUri, + 'normalized_request_uri' => $normalizedUri, + 'normalized_path' => $normalizedPath, + ]; + } + + public static function routeSlugForChannel(string $channelSlug): string + { + $slug = self::safeSlug($channelSlug); + if ($slug === '') { + return ''; + } + return self::RELEASE_ROUTE_SLUGS[$slug] ?? $slug; + } + + public static function channelSlugForRoute(string $routeSlug): string + { + $slug = self::safeSlug($routeSlug); + if ($slug === '') { + return ''; + } + return self::RELEASE_ROUTE_CHANNELS[$slug] ?? $slug; + } + + public static function moduleKeys(): array + { + return self::MODULE_KEYS; + } + + public static function backendVersion(): string + { + foreach (['RELEASE_VERSION', 'GITHUB_SHA', 'COMMIT_SHA', 'VITE_COMMIT_HASH'] as $key) { + $value = self::runtimeEnvValue($key); + if ($value !== '') { + return self::safeIdentifier($value, 128); + } + } + return 'unknown'; + } + + public static function backendCommitSha(): string + { + foreach (['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'] as $key) { + $sha = self::normalizeCommitSha(self::runtimeEnvValue($key)); + if ($sha !== '') { + return $sha; + } + } + + $sha = self::localGitCommitSha(); + return $sha !== '' ? $sha : 'unknown'; + } + + private static function runtimeEnvValue(string $key): string + { + $value = getenv($key); + if (($value === false || trim((string)$value) === '') && array_key_exists($key, $_ENV ?? [])) { + $value = $_ENV[$key]; + } + if (($value === false || trim((string)$value) === '') && array_key_exists($key, $_SERVER ?? [])) { + $value = $_SERVER[$key]; + } + + return is_scalar($value) ? trim((string)$value) : ''; + } + + private static function normalizeCommitSha(string $value): string + { + $value = strtolower(trim($value)); + return preg_match('/^[a-f0-9]{7,40}$/', $value) === 1 ? $value : ''; + } + + private static function localGitCommitSha(): string + { + $base = defined('WD') ? (string)WD : dirname(__DIR__); + $candidates = []; + $current = $base; + + for ($i = 0; $i < 6; $i++) { + if ($current === '' || isset($candidates[$current])) { + break; + } + $candidates[$current] = true; + $parent = dirname($current); + if ($parent === $current) { + break; + } + $current = $parent; + } + + foreach (array_keys($candidates) as $directory) { + $gitPath = $directory . DIRECTORY_SEPARATOR . '.git'; + if (!is_dir($directory) || (!is_dir($gitPath) && !is_file($gitPath))) { + continue; + } + + $output = []; + $exitCode = 1; + @exec('git -C ' . escapeshellarg($directory) . ' rev-parse HEAD 2>&1', $output, $exitCode); + if ($exitCode !== 0 || !isset($output[0])) { + continue; + } + + $sha = self::normalizeCommitSha((string)$output[0]); + if ($sha !== '') { + return $sha; + } + } + + return ''; + } + + public static function verifyGithubSignature(string $secret, string $payload, string $signatureHeader): bool + { + $secret = trim($secret); + $signatureHeader = trim($signatureHeader); + if ($secret === '' || $signatureHeader === '' || !str_starts_with($signatureHeader, 'sha256=')) { + return false; + } + + $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret); + return hash_equals($expected, $signatureHeader); + } + + public function verifyReleaseGateToken(string $token): bool + { + $token = trim($token); + if ($token === '') { + return false; + } + + $expected = trim((string)(getenv('RELEASE_MANAGER_GATE_TOKEN') ?: ($_SERVER['RELEASE_MANAGER_GATE_TOKEN'] ?? ''))); + if ($expected === '') { + $expected = trim((string)$this->moduleConfigValue('ReleaseManager', 'release_gate_token', '')); + } + if ($expected !== '' && str_starts_with($expected, 'twsec:v1:') && class_exists(replication_secret_box::class)) { + try { + $expected = replication_secret_box::decrypt($expected); + } catch (Throwable) { + $expected = ''; + } + } + + return $expected !== '' && hash_equals($expected, $token); + } + + public static function normalizeGithubRepositoryName(string $value): string + { + $repository = trim($value); + if ($repository === '') { + return ''; + } + + if (preg_match('#^git@github\.com:(.+)$#i', $repository, $matches) === 1) { + $repository = $matches[1]; + } elseif (preg_match('#^https?://#i', $repository) === 1) { + $path = parse_url($repository, PHP_URL_PATH); + $repository = is_string($path) ? ltrim($path, '/') : $repository; + } else { + $repository = preg_replace('#^github\.com/#i', '', $repository) ?? $repository; + } + + $repository = preg_replace('#\.git$#i', '', $repository) ?? $repository; + $repository = trim($repository, "/ \t\n\r\0\x0B"); + return preg_match('/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/', $repository) === 1 ? $repository : ''; + } + + 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 (self::isSensitiveKey($keyString)) { + $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)) { + if (strlen($value) > 4000) { + return substr($value, 0, 4000) . "\n... [truncated]"; + } + return $value; + } + + return $value; + } + + public static function deploymentCanBePromoted(string $status): bool + { + return in_array(strtolower(trim($status)), ['deployed'], true); + } + + public static function deploymentPromotionBlockedReason(array $deployment): string + { + $status = strtolower(trim((string)($deployment['status'] ?? 'unknown'))) ?: 'unknown'; + $result = self::jsonDecode($deployment['result_json'] ?? null); + if ($result === [] && is_array($deployment['result'] ?? null)) { + $result = $deployment['result']; + } + $failure = is_array($result['failure_summary'] ?? null) ? $result['failure_summary'] : []; + $rootCause = trim((string)($failure['root_cause'] ?? $deployment['error_message'] ?? '')); + + if ($status === 'active') { + return 'Deployment is already active.'; + } + + if ($status === 'failed') { + return 'Deployment failed and cannot be promoted.' . ($rootCause !== '' ? ' Cause: ' . $rootCause : ''); + } + + return 'Only successfully deployed release deployments can be promoted. Current status: ' . $status . '.'; + } + + public static function deploymentFailureSummary(Throwable $throwable, array $context = []): array + { + $message = trim($throwable->getMessage()) ?: 'Deployment failed without an error message.'; + $normalized = strtolower($message); + $category = 'unknown'; + $stage = trim((string)($context['stage'] ?? 'deployment')) ?: 'deployment'; + $nextAction = 'Open the Coolify deployment logs for the service and compare the failing commit with the last successful deployment.'; + + if (str_contains($normalized, 'github repository access') || str_contains($normalized, 'github api')) { + $category = 'github_access'; + $stage = 'source_access'; + $nextAction = 'Verify the Release Manager GitHub token, repository, branch, and selected commit before deploying again.'; + } elseif ( + str_contains($normalized, 'coolify instance') + || str_contains($normalized, 'base url') + || str_contains($normalized, 'api token') + ) { + $category = 'coolify_connection'; + $stage = 'provider_connection'; + $nextAction = 'Test the configured Coolify instance and API token from Release Manager settings.'; + } elseif ( + str_contains($normalized, 'service uuid') + || str_contains($normalized, 'select an existing coolify service') + || str_contains($normalized, 'http 404') + ) { + $category = 'coolify_target'; + $stage = 'provider_target'; + $nextAction = 'Check that the saved deployment target points at the correct Coolify service UUID and instance.'; + } elseif ( + str_contains($normalized, 'docker_compose') + || str_contains($normalized, 'explicit image') + || str_contains($normalized, 'image/repository') + || str_contains($normalized, 'pull access denied') + || str_contains($normalized, 'manifest') + || str_contains($normalized, 'denied') + || str_contains($normalized, 'validation') + ) { + $category = 'configuration'; + $stage = 'provider_configuration'; + $nextAction = 'Review the deployment target image, registry access, compose payload, and required environment variables.'; + } elseif (str_contains($normalized, 'health') || str_contains($normalized, 'smoke')) { + $category = 'smoke_test'; + $stage = 'post_deploy_smoke_test'; + $nextAction = 'Check container startup logs and the configured health URL before promoting the deployment.'; + } elseif ( + str_contains($normalized, 'timeout') + || str_contains($normalized, 'timed out') + || str_contains($normalized, 'could not connect') + || str_contains($normalized, 'network') + ) { + $category = 'network'; + $stage = 'provider_connection'; + $nextAction = 'Check network access from the API container to GitHub and Coolify, then retry the deployment.'; + } + + $evidence = array_filter([ + 'message' => $message, + 'app' => $context['app'] ?? null, + 'repository' => $context['repository'] ?? null, + 'branch' => $context['branch'] ?? null, + 'commit_sha' => $context['commit_sha'] ?? null, + 'target_id' => $context['target_id'] ?? null, + 'coolify_instance_id' => $context['coolify_instance_id'] ?? null, + 'coolify_service_uuid' => $context['coolify_service_uuid'] ?? null, + ], static fn(mixed $value): bool => $value !== null && $value !== ''); + + return [ + 'category' => $category, + 'stage' => $stage, + 'root_cause' => $message, + 'next_action' => $nextAction, + 'promotion_blocked' => true, + 'captured_at' => date('c'), + 'evidence' => self::redactPayload($evidence), + ]; + } + + public static function recordBackendFailure(bool $success, mixed $data, ?int $status): void + { + if ($success || ($status !== null && $status < 400)) { + return; + } + + $uri = (string)($_SERVER['REQUEST_URI'] ?? ''); + if (str_starts_with($uri, '/release/timeline/events')) { + return; + } + + try { + if (!isset($GLOBALS['db']) || !release_manager_schema_bootstrap::tablesExist()) { + return; + } + + $context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null) + ? $GLOBALS['RELEASE_REQUEST_CONTEXT'] + : self::initializeRequestContext(); + + $manager = new self(); + $principalContext = $manager->currentPrincipalContext(); + $channel = $manager->resolveChannel($principalContext); + $manager->ingestTimelineEvents([ + [ + 'type' => 'backend_response_failed', + 'severity' => ($status ?? 500) >= 500 ? 'error' : 'warning', + 'module_key' => $manager->inferModuleKeyFromUri($uri), + 'route' => explode('?', $uri)[0] ?: '/', + 'occurred_at' => date('c'), + 'payload' => [ + 'status' => $status, + 'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET', + 'response' => $data, + ], + ], + ], [ + 'trace_id' => $context['trace_id'] ?? '', + 'channel_slug' => $channel['slug'] ?? '', + 'principal_type' => $principalContext['principal_type'] ?? null, + 'principal_id' => $principalContext['principal_id'] ?? null, + 'customer_number' => $principalContext['customer_number'] ?? null, + ], false); + } catch (Throwable) { + // Release telemetry must never block API responses. + } + } + + public function bootstrap(): array + { + $this->ensureSchema(); + $channel = $this->defaultChannel(); + $versions = $this->currentVersionsForChannel((int)$channel['id']); + $urls = $this->releaseRuntimeUrls($channel, $versions); + + return [ + 'source' => 'deployment', + 'generated_at' => date('c'), + 'trace_id' => $this->requestTraceId(), + 'channel' => $this->publicRuntimeChannel($channel), + 'versions' => $versions, + 'frontend_base_url' => $urls['frontend_base_url'], + 'api_base_url' => $urls['api_base_url'], + 'urls' => $urls, + 'availability' => $this->channelAvailability($channel), + 'capture_policy' => [ + 'enabled' => false, + 'capture_level' => 'metadata', + 'all_failure_metadata' => true, + 'retention_days' => (int)($channel['retention_days'] ?? 14), + ], + 'available_channels' => $this->publicRuntimeChannelOptions([$channel]), + 'selected_channel_slug' => (string)($channel['slug'] ?? ''), + ]; + } + + public function runtimeForPayload(array $payload, array $input = []): array + { + $this->ensureSchema(); + + $context = [ + 'principal_type' => 'user', + 'principal_id' => isset($payload['id']) ? (string)$payload['id'] : null, + 'customer_number' => isset($payload['customer_number']) ? (int)$payload['customer_number'] : null, + ]; + + $resolvedChannel = $this->resolveChannel($context); + $availableChannels = $this->runtimeChannelsForContext($context, $resolvedChannel); + $channel = $this->chooseRuntimeChannel( + $resolvedChannel, + $availableChannels, + $this->requestedRuntimeChannelSlug($input) + ); + $serviceChannel = $this->runtimeServiceChannelFor($channel); + $versions = $this->currentVersionsForChannel((int)$serviceChannel['id']); + $capturePolicy = $this->capturePolicyFor($context, $channel); + $urls = $this->releaseRuntimeUrls($serviceChannel, $versions); + + return [ + 'source' => 'deployment', + 'generated_at' => date('c'), + 'trace_id' => $this->requestTraceId(), + 'channel' => $this->publicRuntimeChannel($channel), + 'service_channel' => $this->publicRuntimeChannel($serviceChannel), + 'versions' => $versions, + 'frontend_base_url' => $urls['frontend_base_url'], + 'api_base_url' => $urls['api_base_url'], + 'urls' => $urls, + 'availability' => $this->channelAvailability($channel), + 'capture_policy' => $capturePolicy, + 'available_channels' => $this->publicRuntimeChannelOptions($availableChannels), + 'selected_channel_slug' => (string)($channel['slug'] ?? ''), + 'selected_service_channel_slug' => (string)($serviceChannel['slug'] ?? ''), + 'module_keys' => self::MODULE_KEYS, + ]; + } + + public function runtimeForCurrentPrincipal(array $input = []): array + { + $this->ensureSchema(); + $context = $this->currentPrincipalContext(); + $resolvedChannel = $this->resolveChannel($context); + $availableChannels = $this->runtimeChannelsForContext($context, $resolvedChannel); + $channel = $this->chooseRuntimeChannel( + $resolvedChannel, + $availableChannels, + $this->requestedRuntimeChannelSlug($input) + ); + + $serviceChannel = $this->runtimeServiceChannelFor($channel); + $versions = $this->currentVersionsForChannel((int)$serviceChannel['id']); + $urls = $this->releaseRuntimeUrls($serviceChannel, $versions); + + return [ + 'source' => 'deployment', + 'generated_at' => date('c'), + 'trace_id' => $this->requestTraceId(), + 'channel' => $this->publicRuntimeChannel($channel), + 'service_channel' => $this->publicRuntimeChannel($serviceChannel), + 'versions' => $versions, + 'frontend_base_url' => $urls['frontend_base_url'], + 'api_base_url' => $urls['api_base_url'], + 'urls' => $urls, + 'availability' => $this->channelAvailability($channel), + 'capture_policy' => $this->capturePolicyFor($context, $channel), + 'available_channels' => $this->publicRuntimeChannelOptions($availableChannels), + 'selected_channel_slug' => (string)($channel['slug'] ?? ''), + 'selected_service_channel_slug' => (string)($serviceChannel['slug'] ?? ''), + 'module_keys' => self::MODULE_KEYS, + ]; + } + + public function enabledReleaseChannelSlugs(): array + { + $this->ensureSchema(); + return array_values(array_filter(array_map( + static fn(array $channel): string => self::safeSlug((string)($channel['slug'] ?? '')), + $this->selectRows("SELECT slug FROM release_channels WHERE deleted_at IS NULL AND enabled = 1") + ))); + } + + public function summary(): array + { + $this->ensureSchema(); + $this->cleanupExpiredReplayData(); + + $summary = [ + 'generated_at' => date('c'), + 'channels' => $this->listChannels(), + 'assignments' => $this->listAssignments(), + 'deployment_targets' => $this->listDeploymentTargets(), + 'service_sets' => $this->listServiceSets(), + 'bundles' => $this->listBundles(25), + 'deployments' => $this->listDeployments(25), + 'operations' => $this->listOperations(['limit' => 20]), + 'data_services' => $this->releaseDataServicesSummary(), + 'replication_policy' => $this->releaseReplicationPolicySummary(), + 'coolify' => $this->releaseCoolifySummary(), + 'failover' => $this->releaseFailoverSummary(), + 'timeline' => $this->timelineSummary(), + 'module_health' => $this->latestModuleHealth(), + 'module_keys' => self::MODULE_KEYS, + 'suggestions' => $this->releaseSuggestions(), + ]; + $summary['status_overview'] = $this->releaseStatusOverview($summary); + + return $summary; + } + + public function suggestions(): array + { + $this->ensureSchema(); + return $this->releaseSuggestions(); + } + + public function listOperations(array $filters = []): array + { + if (!release_manager_schema_bootstrap::tablesExist()) { + return []; + } + + $limit = max(1, min(100, (int)($filters['limit'] ?? 50))); + $where = []; + $types = ''; + $params = []; + + $channelId = $this->nullablePositiveInt($filters['channel_id'] ?? null); + if ($channelId !== null) { + $where[] = 'r.channel_id = ?'; + $types .= 'i'; + $params[] = $channelId; + } + + $operationType = self::safeIdentifier((string)($filters['operation_type'] ?? $filters['type'] ?? ''), 64); + if ($operationType !== '') { + $where[] = 'r.operation_type = ?'; + $types .= 's'; + $params[] = $operationType; + } + + $status = self::safeIdentifier((string)($filters['status'] ?? ''), 32); + if ($status !== '') { + $where[] = 'r.status = ?'; + $types .= 's'; + $params[] = $status; + } + + $whereSql = $where !== [] ? 'WHERE ' . implode(' AND ', $where) : ''; + $rows = $this->selectRows( + "SELECT r.*, c.slug AS channel_slug, c.name AS channel_name, + (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id) AS step_count, + (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('passed', 'deployed')) AS passed_step_count, + (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status = 'failed') AS failed_step_count, + (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('warning', 'skipped')) AS warning_step_count + FROM release_operation_runs r + LEFT JOIN release_channels c ON c.id = r.channel_id + $whereSql + ORDER BY r.created_at DESC, r.id DESC + LIMIT $limit", + $types, + $params + ); + + return array_map(fn(array $row): array => $this->publicOperationRun($row, false), $rows); + } + + public function operationDetail(int $id): array + { + $this->ensureSchema(); + $operation = $this->getOperationRun($id); + return $this->publicOperationRun($operation, true); + } + + public function runReleaseTest(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + + $channel = null; + try { + if ($this->nullablePositiveInt($input['channel_id'] ?? null) !== null || trim((string)($input['channel_slug'] ?? $input['channel'] ?? '')) !== '') { + $channel = $this->channelFromInput($input); + } + } catch (Throwable $throwable) { + $channel = null; + } + $gateInput = $this->normalizeReleaseGateInput($input, $channel); + + $operationId = $this->createOperationRun('release_test', [ + 'subject_type' => $channel !== null ? 'channel' : 'release_manager', + 'subject_id' => $channel !== null ? (string)$channel['id'] : null, + 'channel_id' => $channel !== null ? (int)$channel['id'] : null, + 'title' => $channel !== null + ? sprintf('Release checks for %s', (string)($channel['name'] ?? $channel['slug'] ?? 'channel')) + : 'Release Manager checks', + 'actor_user_id' => $actorUserId, + 'context' => array_replace(self::redactPayload($input), [ + 'release_gate' => self::redactPayload($gateInput), + ]), + ]); + + $statuses = []; + $this->recordOperationStep($operationId, 'dashboard_contract', 'Dashboard data contract', 'passed', 'Release Manager exposes channels, operations, Coolify, failover, and data-service state.', null, null, [ + 'summary_keys' => ['channels', 'operations', 'coolify', 'failover', 'data_services'], + ]); + $statuses[] = 'passed'; + + if ($this->releaseGateAutoSyncRequested($gateInput)) { + foreach ($this->releaseGateAutoSyncValidationSteps($gateInput, $channel) as $autoSyncStep) { + $this->recordOperationStep( + $operationId, + (string)$autoSyncStep['step_key'], + (string)$autoSyncStep['label'], + (string)$autoSyncStep['status'], + $autoSyncStep['message'] ?? null, + $autoSyncStep['diagnostic'] ?? null, + $autoSyncStep['solution_hint'] ?? null, + is_array($autoSyncStep['context'] ?? null) ? $autoSyncStep['context'] : [] + ); + $statuses[] = (string)$autoSyncStep['status']; + } + } + + if (($gateInput['required_checks'] ?? []) !== []) { + $this->recordOperationStep( + $operationId, + 'release_gate_inputs', + 'Release gate payload', + 'passed', + 'Release gate payload includes CI deploy metadata and required checks.', + null, + null, + $gateInput + ); + $statuses[] = 'passed'; + + foreach ($this->runReleaseGateChecks($gateInput) as $gateStep) { + $this->recordOperationStep( + $operationId, + (string)$gateStep['step_key'], + (string)$gateStep['label'], + (string)$gateStep['status'], + $gateStep['message'] ?? null, + $gateStep['diagnostic'] ?? null, + $gateStep['solution_hint'] ?? null, + is_array($gateStep['context'] ?? null) ? $gateStep['context'] : [] + ); + $statuses[] = (string)$gateStep['status']; + } + } + + $appsToCheck = $this->releaseTestAppsFromInput($input); + $channels = $channel !== null ? [$channel] : $this->selectRows("SELECT * FROM release_channels WHERE deleted_at IS NULL AND enabled = 1 ORDER BY default_channel DESC, slug"); + foreach ($channels as $testChannel) { + foreach ($appsToCheck as $app) { + $target = $this->deploymentTargetForChannelApp((int)$testChannel['id'], $app); + $repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app))); + $branch = self::releaseBranchForChannel($testChannel); + if ($target === null) { + $this->recordOperationStep( + $operationId, + sprintf('%s_%s_target', (string)$testChannel['slug'], $app), + sprintf('%s %s target', (string)$testChannel['name'], strtoupper($app)), + 'warning', + 'No Coolify deployment target is configured for this app/channel pair.', + 'Release Manager cannot deploy this app until a target exists.', + 'Create or repair the channel deployment target, then run the test again.', + ['channel_slug' => $testChannel['slug'], 'app' => $app, 'retry_action' => 'configure_target'] + ); + $statuses[] = 'warning'; + continue; + } + + $access = $this->githubRepositoryAccess([ + 'repository' => $repository, + 'branch' => $branch, + 'commit_mode' => 'latest', + ]); + $ok = (bool)($access['ok'] ?? false); + $status = $ok ? 'passed' : 'warning'; + $statuses[] = $status; + $this->recordOperationStep( + $operationId, + sprintf('%s_%s_branch', (string)$testChannel['slug'], $app), + sprintf('%s %s branch', (string)$testChannel['name'], strtoupper($app)), + $status, + $ok + ? sprintf('Branch %s is reachable and resolves to the latest commit.', $branch) + : sprintf('Branch %s could not be verified and will be skipped by sync.', $branch), + $ok ? null : (string)($access['message'] ?? 'GitHub branch access failed.'), + $ok ? null : 'Create the missing branch or repair the Release Manager GitHub token, then use Retry.', + [ + 'channel_slug' => $testChannel['slug'], + 'route_slug' => self::routeSlugForChannel((string)$testChannel['slug']), + 'app' => $app, + 'repository' => $repository, + 'branch' => $branch, + 'github_access' => $access, + 'retry_action' => 'retry_branch_check', + ] + ); + } + } + + $dataSummary = $channel !== null + ? $this->channelDataServicesSummary($channel) + : $this->releaseDataServicesSummary(); + $this->recordOperationStep($operationId, 'data_services', 'Production-shared data services', 'passed', 'Normal channel sync keeps MariaDB, Redis, and MinIO on production_shared unless an explicit data-service action changes that mode.', null, null, [ + 'data_services' => $dataSummary, + ]); + $statuses[] = 'passed'; + + $finalStatus = in_array('failed', $statuses, true) + ? 'failed' + : (count(array_intersect($statuses, ['warning', 'skipped'])) > 0 ? 'warning' : 'passed'); + + if ($finalStatus === 'passed' && $this->releaseGateAutoSyncRequested($gateInput)) { + try { + $this->inProcessPassedReleaseGates[$operationId] = [ + 'channel_id' => $channel !== null ? (int)$channel['id'] : null, + 'release_gate' => $gateInput, + ]; + $autoSyncResult = $this->processReleaseGateAutoSync($gateInput, $channel, $operationId, $actorUserId); + $autoSyncStepStatus = (string)($autoSyncResult['step_status'] ?? 'passed'); + $this->recordOperationStep( + $operationId, + 'auto_sync', + 'Automatic container update', + $autoSyncStepStatus, + (string)($autoSyncResult['message'] ?? 'Automatic container update completed.'), + $autoSyncResult['diagnostic'] ?? null, + $autoSyncResult['solution_hint'] ?? null, + $autoSyncResult + ); + $statuses[] = $autoSyncStepStatus; + } catch (Throwable $throwable) { + $this->recordOperationStep( + $operationId, + 'auto_sync', + 'Automatic container update', + 'failed', + 'Automatic container update failed after the release gate passed.', + $throwable->getMessage(), + 'Open the channel sync operation or Release Manager target diagnostics, fix the failure, then rerun the gate.', + $gateInput + ); + $statuses[] = 'failed'; + } + + $finalStatus = in_array('failed', $statuses, true) + ? 'failed' + : (count(array_intersect($statuses, ['warning', 'skipped'])) > 0 ? 'warning' : 'passed'); + } + + $this->completeOperationRun( + $operationId, + $finalStatus, + match ($finalStatus) { + 'passed' => 'Release Manager tests completed without detected issues.', + 'failed' => 'Release Manager tests failed. Promotion is blocked until the failed gate checks pass.', + default => 'Release Manager tests completed with warnings. Open the failed or warning steps for fixes.', + }, + $finalStatus === 'passed' ? null : 'Resolve the failed or warning steps, then run the test again.' + ); + + return $this->operationDetail($operationId); + } + + private function releaseTestAppsFromInput(array $input): array + { + $raw = $input['apps'] ?? $input['app'] ?? null; + $values = $this->releaseGateStringArray($raw); + $apps = []; + foreach ($values as $value) { + try { + $app = $this->normalizeApp($value); + } catch (Throwable) { + continue; + } + if (!in_array($app, $apps, true)) { + $apps[] = $app; + } + } + + return $apps !== [] ? $apps : self::APPS; + } + + private function normalizeReleaseGateInput(array $input, ?array $channel): array + { + $channelSlug = self::safeSlug((string)($input['channel_slug'] ?? $input['channel'] ?? ($channel['slug'] ?? ''))); + $environmentUrl = $this->normalizeReleaseGateUrl((string)($input['environment_url'] ?? $input['frontend_url'] ?? '')); + $apiBaseUrl = $this->normalizeReleaseGateUrl((string)($input['api_base_url'] ?? 'https://api-v2.truckwash.io')); + $requiredChecks = $this->normalizeReleaseGateChecks($input, $environmentUrl); + $routeSlug = self::routeSlugForChannel($channelSlug ?: 'stable') ?: 'master'; + $app = ''; + if (trim((string)($input['app'] ?? '')) !== '') { + try { + $app = $this->normalizeApp((string)$input['app']); + } catch (Throwable) { + $app = ''; + } + } + $repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? $input['repo'] ?? '')); + $branch = trim((string)($input['branch'] ?? '')); + $workflowUrl = $this->normalizeReleaseGateUrl((string)($input['workflow_url'] ?? $input['build_url'] ?? '')); + + return [ + 'environment_url' => $environmentUrl, + 'channel_slug' => $channelSlug, + 'route_slug' => $routeSlug, + 'app' => $app, + 'repository' => $repository, + 'branch' => $branch, + 'auto_sync' => $this->toBool($input['auto_sync'] ?? false), + 'workflow_url' => $workflowUrl, + 'expected_commit' => self::safeIdentifier((string)($input['expected_commit'] ?? $input['commit_sha'] ?? ''), 128), + 'build_id' => substr(trim((string)($input['build_id'] ?? '')), 0, 128), + 'wait_timeout_seconds' => max(0, min(300, (int)($input['wait_timeout_seconds'] ?? 300))), + 'poll_interval_seconds' => max(1, min(60, (int)($input['poll_interval_seconds'] ?? 10))), + 'required_checks' => $requiredChecks, + 'api_base_url' => $apiBaseUrl, + 'api_ping_paths' => $this->releaseGateStringArray( + $input['api_ping_paths'] + ?? $input['api_paths'] + ?? ['/master/api/ping'] + ), + 'shell_paths' => $this->releaseGateStringArray($input['shell_paths'] ?? ['/', '/guest/book/wash']), + ]; + } + + private function normalizeReleaseGateChecks(array $input, string $environmentUrl): array + { + $checks = $this->releaseGateStringArray($input['required_checks'] ?? []); + if ($checks === [] && $environmentUrl !== '') { + $checks = ['static_artifact']; + } + + $allowed = ['static_artifact', 'api_gateway']; + $normalized = []; + foreach ($checks as $check) { + $check = self::safeIdentifier(strtolower($check), 64); + if (in_array($check, $allowed, true) && !in_array($check, $normalized, true)) { + $normalized[] = $check; + } + } + + return $normalized; + } + + private function releaseGateAutoSyncRequested(array $gateInput): bool + { + return (bool)($gateInput['auto_sync'] ?? false); + } + + private function releaseGateAutoSyncValidationSteps(array $gateInput, ?array $channel): array + { + $steps = []; + $requiredChecks = is_array($gateInput['required_checks'] ?? null) ? $gateInput['required_checks'] : []; + $context = [ + 'channel_slug' => $gateInput['channel_slug'] ?? null, + 'app' => $gateInput['app'] ?? null, + 'repository' => $gateInput['repository'] ?? null, + 'branch' => $gateInput['branch'] ?? null, + 'expected_commit' => $gateInput['expected_commit'] ?? null, + 'workflow_url' => $gateInput['workflow_url'] ?? null, + 'required_checks' => $requiredChecks, + ]; + + if ($channel === null) { + $steps[] = [ + 'step_key' => 'auto_sync_channel', + 'label' => 'Automatic update channel', + 'status' => 'failed', + 'message' => 'Automatic container updates require a release channel.', + 'diagnostic' => 'The gate payload did not resolve to a configured release channel.', + 'solution_hint' => 'Pass channel_slug from CI, for example stable for master.', + 'context' => $context, + ]; + } + if (trim((string)($gateInput['app'] ?? '')) === '') { + $steps[] = [ + 'step_key' => 'auto_sync_app', + 'label' => 'Automatic update app', + 'status' => 'failed', + 'message' => 'Automatic container updates require an app.', + 'diagnostic' => 'The gate payload must identify frontend or api so Release Manager updates exactly one container.', + 'solution_hint' => 'Pass app=frontend from the frontend workflow or app=api from the backend workflow.', + 'context' => $context, + ]; + } + if (trim((string)($gateInput['expected_commit'] ?? '')) === '') { + $steps[] = [ + 'step_key' => 'auto_sync_commit', + 'label' => 'Automatic update commit', + 'status' => 'failed', + 'message' => 'Automatic container updates require the CI-verified commit SHA.', + 'diagnostic' => 'expected_commit was empty.', + 'solution_hint' => 'Pass github.sha as expected_commit in the release gate payload.', + 'context' => $context, + ]; + } + if ($requiredChecks === []) { + $steps[] = [ + 'step_key' => 'auto_sync_required_checks', + 'label' => 'Automatic update required checks', + 'status' => 'failed', + 'message' => 'Automatic container updates require at least one release gate check.', + 'diagnostic' => 'required_checks was empty.', + 'solution_hint' => 'Include required_checks (for example static_artifact and/or api_gateway) in the release gate payload.', + 'context' => $context, + ]; + } + + if ($steps === []) { + $steps[] = [ + 'step_key' => 'auto_sync_inputs', + 'label' => 'Automatic update inputs', + 'status' => 'passed', + 'message' => 'Release gate payload includes app, channel, and exact commit metadata for automatic container updates.', + 'context' => $context, + ]; + } + + return $steps; + } + + private function releaseGateStringArray(mixed $value): array + { + if (is_string($value)) { + $value = preg_split('/\s*,\s*/', trim($value)) ?: []; + } + if (!is_array($value)) { + return []; + } + + $values = []; + foreach ($value as $item) { + $item = trim((string)$item); + if ($item !== '' && !in_array($item, $values, true)) { + $values[] = $item; + } + } + return $values; + } + + private function normalizeReleaseGateUrl(string $value): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + if (preg_match('#^https?://#i', $value) !== 1) { + $value = 'https://' . ltrim($value, '/'); + } + $parts = parse_url($value); + if (!is_array($parts) || empty($parts['host'])) { + return ''; + } + + return rtrim($value, '/'); + } + + private function runReleaseGateChecks(array $gateInput): array + { + $steps = []; + foreach ($gateInput['required_checks'] as $check) { + $steps[] = match ($check) { + 'static_artifact' => $this->verifyReleaseStaticArtifact($gateInput), + 'api_gateway' => $this->verifyReleaseApiGateway($gateInput), + default => [ + 'step_key' => $check, + 'label' => 'Unknown release gate check', + 'status' => 'skipped', + 'message' => 'Unknown release gate check was skipped.', + 'context' => ['check' => $check], + ], + }; + } + + return $steps; + } + + private function assertReleaseGatePassedForPromotion(int $channelId, ?string $expectedCommit = null, ?string $buildId = null, ?string $app = null): void + { + if (!$this->releaseGateRequiredForPromotion()) { + return; + } + + $expectedCommit = trim((string)$expectedCommit); + $buildId = trim((string)$buildId); + $app = trim((string)$app) !== '' ? $this->normalizeApp((string)$app) : ''; + foreach ($this->inProcessPassedReleaseGates as $inProcessGate) { + if ((int)($inProcessGate['channel_id'] ?? 0) !== $channelId) { + continue; + } + $gate = is_array($inProcessGate['release_gate'] ?? null) ? $inProcessGate['release_gate'] : []; + if ($app !== '' && !$this->releaseGateAppMatches($gate, $app)) { + continue; + } + if ($expectedCommit !== '' && !$this->releaseGateCommitMatches((string)($gate['expected_commit'] ?? ''), $expectedCommit)) { + continue; + } + if ($buildId !== '' && (string)($gate['build_id'] ?? '') !== $buildId) { + continue; + } + return; + } + + $rows = $this->selectRows( + "SELECT id, context_json, completed_at + FROM release_operation_runs + WHERE operation_type = 'release_test' + AND channel_id = ? + AND status = 'passed' + AND completed_at >= DATE_SUB(NOW(), INTERVAL 12 HOUR) + ORDER BY completed_at DESC, id DESC + LIMIT 20", + 'i', + [$channelId] + ); + + foreach ($rows as $row) { + $context = json_decode((string)($row['context_json'] ?? ''), true); + $gate = is_array($context['release_gate'] ?? null) ? $context['release_gate'] : []; + if ($app !== '' && !$this->releaseGateAppMatches($gate, $app)) { + continue; + } + if ($expectedCommit !== '' && !$this->releaseGateCommitMatches((string)($gate['expected_commit'] ?? ''), $expectedCommit)) { + continue; + } + if ($buildId !== '' && (string)($gate['build_id'] ?? '') !== $buildId) { + continue; + } + + return; + } + + throw new RuntimeException('A passing Release Manager gate is required before promotion. Run the dev upload, public live smoke, credentialed smoke, and api-v2 health checks, then retry promotion.'); + } + + private function releaseGateAppMatches(array $gate, string $app): bool + { + $app = $this->normalizeApp($app); + $gateApp = trim((string)($gate['app'] ?? '')); + if ($gateApp !== '') { + try { + return $this->normalizeApp($gateApp) === $app; + } catch (Throwable) { + return false; + } + } + + $gateApps = $this->releaseGateStringArray($gate['apps'] ?? []); + if ($gateApps !== []) { + foreach ($gateApps as $value) { + try { + if ($this->normalizeApp($value) === $app) { + return true; + } + } catch (Throwable) { + } + } + return false; + } + + // Gates recorded before app-specific payloads existed were frontend release gates. + return $app === 'frontend'; + } + + private function releaseGateRequiredForPromotion(): bool + { + $envValue = trim((string)(getenv('RELEASE_GATE_REQUIRED_FOR_PROMOTION') ?: ($_SERVER['RELEASE_GATE_REQUIRED_FOR_PROMOTION'] ?? ''))); + if ($envValue !== '') { + return $this->toBool($envValue); + } + + return $this->toBool($this->moduleConfigValue('ReleaseManager', 'release_gate_required_for_promotion', 'true')); + } + + private function verifyReleaseStaticArtifact(array $gateInput): array + { + if (($gateInput['environment_url'] ?? '') === '') { + return [ + 'step_key' => 'static_artifact', + 'label' => 'Static artifact deployment', + 'status' => 'failed', + 'message' => 'environment_url is required for static artifact verification.', + 'solution_hint' => 'Pass the dev or channel frontend URL from CI.', + 'context' => $gateInput, + ]; + } + + $deadline = time() + (int)$gateInput['wait_timeout_seconds']; + $pollInterval = (int)$gateInput['poll_interval_seconds']; + $attempts = 0; + $lastMessage = 'Static artifact verification did not run.'; + do { + $attempts++; + try { + $context = $this->releaseStaticArtifactAttempt($gateInput); + $context['attempts'] = $attempts; + return [ + 'step_key' => 'static_artifact', + 'label' => 'Static artifact deployment', + 'status' => 'passed', + 'message' => 'The exact release manifest, app shell, JS, CSS, PWA assets, and release entry are reachable.', + 'context' => $context, + ]; + } catch (Throwable $throwable) { + $lastMessage = $throwable->getMessage(); + if (time() >= $deadline) { + break; + } + sleep($pollInterval); + } + } while (true); + + return [ + 'step_key' => 'static_artifact', + 'label' => 'Static artifact deployment', + 'status' => 'failed', + 'message' => 'The uploaded frontend artifact is not ready or does not match the expected build.', + 'diagnostic' => $lastMessage, + 'solution_hint' => 'Upload hashed assets first, keep old hashed assets, upload release-entry.json and index.html last, then rerun the gate.', + 'context' => [ + 'environment_url' => $gateInput['environment_url'], + 'attempts' => $attempts, + 'wait_timeout_seconds' => $gateInput['wait_timeout_seconds'], + 'poll_interval_seconds' => $gateInput['poll_interval_seconds'], + ], + ]; + } + + private function releaseStaticArtifactAttempt(array $gateInput): array + { + $baseUrl = (string)$gateInput['environment_url']; + $manifest = $this->releaseGateFetchJson($baseUrl, 'release-manifest.json'); + $releaseEntry = $this->releaseGateFetchJson($baseUrl, 'release-entry.json'); + $manifestData = $manifest['json']; + $releaseEntryData = $releaseEntry['json']; + + if (trim((string)($manifestData['build_id'] ?? '')) === '') { + throw new RuntimeException('release-manifest.json is missing build_id.'); + } + if (!$this->releaseGateCommitMatches((string)($manifestData['commit_sha'] ?? ''), (string)$gateInput['expected_commit'])) { + throw new RuntimeException(sprintf( + 'release-manifest.json commit_sha %s did not match expected commit %s.', + (string)($manifestData['commit_sha'] ?? '(missing)'), + (string)$gateInput['expected_commit'] + )); + } + if ((string)$gateInput['build_id'] !== '' && (string)($manifestData['build_id'] ?? '') !== (string)$gateInput['build_id']) { + throw new RuntimeException(sprintf( + 'release-manifest.json build_id %s did not match expected build_id %s.', + (string)($manifestData['build_id'] ?? '(missing)'), + (string)$gateInput['build_id'] + )); + } + if ((string)($releaseEntryData['entry'] ?? '') !== (string)($manifestData['entry'] ?? '')) { + throw new RuntimeException('release-entry.json entry does not match release-manifest.json.'); + } + if (json_encode($releaseEntryData['css'] ?? []) !== json_encode($manifestData['css'] ?? [])) { + throw new RuntimeException('release-entry.json css does not match release-manifest.json.'); + } + + foreach ($gateInput['shell_paths'] as $shellPath) { + $shell = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $shellPath)); + if (($shell['status'] ?? 0) !== 200) { + throw new RuntimeException(sprintf('%s returned HTTP %d.', $shellPath, (int)($shell['status'] ?? 0))); + } + if (!str_contains(strtolower((string)($shell['content_type'] ?? '')), 'text/html')) { + throw new RuntimeException(sprintf('%s did not return HTML.', $shellPath)); + } + $body = (string)($shell['body'] ?? ''); + if (strlen(trim(preg_replace('/\s+/', '', $body) ?? '')) < 40) { + throw new RuntimeException(sprintf('%s returned an empty app shell.', $shellPath)); + } + if (!str_contains($body, '
')) { + throw new RuntimeException(sprintf('%s did not include the Vue app root.', $shellPath)); + } + } + + $assetUrls = $this->releaseGateUniqueStrings(array_merge( + ['release-manifest.json', 'release-entry.json'], + [(string)($manifestData['entry'] ?? '')], + is_array($manifestData['css'] ?? null) ? $manifestData['css'] : [], + is_array($manifestData['index_asset_urls'] ?? null) ? $manifestData['index_asset_urls'] : [], + is_array($manifestData['pwa_asset_urls'] ?? null) ? $manifestData['pwa_asset_urls'] : [], + is_array($manifestData['asset_urls'] ?? null) ? $manifestData['asset_urls'] : [] + )); + $verifiedAssets = 0; + foreach ($assetUrls as $assetUrl) { + if ($assetUrl === '/index.html') { + continue; + } + $this->releaseGateVerifyStaticAsset($baseUrl, $assetUrl, is_array($manifestData['asset_hashes'] ?? null) ? $manifestData['asset_hashes'] : []); + $verifiedAssets++; + } + + return [ + 'environment_url' => $baseUrl, + 'build_id' => (string)$manifestData['build_id'], + 'commit_sha' => (string)($manifestData['commit_sha'] ?? ''), + 'entry' => (string)$manifestData['entry'], + 'verified_assets' => $verifiedAssets, + ]; + } + + private function verifyReleaseApiGateway(array $gateInput): array + { + $apiBaseUrl = (string)($gateInput['api_base_url'] ?? ''); + if ($apiBaseUrl === '') { + return [ + 'step_key' => 'api_gateway', + 'label' => 'api-v2 channel health', + 'status' => 'failed', + 'message' => 'api_base_url is required for API gateway verification.', + 'solution_hint' => 'Pass the api-v2 base URL from CI.', + 'context' => $gateInput, + ]; + } + + $checked = []; + try { + foreach ($gateInput['api_ping_paths'] as $path) { + $json = $this->releaseGateFetchJson($apiBaseUrl, $path); + $payload = $json['json']; + if (array_key_exists('success', $payload) && $payload['success'] !== true) { + throw new RuntimeException(sprintf('%s returned success=false.', $path)); + } + $checked[] = [ + 'path' => $path, + 'status' => $json['status'], + ]; + } + } catch (Throwable $throwable) { + return [ + 'step_key' => 'api_gateway', + 'label' => 'api-v2 channel health', + 'status' => 'failed', + 'message' => 'api-v2 gateway or channel API health failed.', + 'diagnostic' => $throwable->getMessage(), + 'solution_hint' => 'Repair api-v2 routing so the configured channel API ping endpoints return 200 JSON before frontend promotion.', + 'context' => [ + 'api_base_url' => $apiBaseUrl, + 'checked' => $checked, + 'api_ping_paths' => $gateInput['api_ping_paths'], + ], + ]; + } + + return [ + 'step_key' => 'api_gateway', + 'label' => 'api-v2 channel health', + 'status' => 'passed', + 'message' => 'api-v2 gateway and channel API prefixes returned 200 JSON.', + 'context' => [ + 'api_base_url' => $apiBaseUrl, + 'checked' => $checked, + ], + ]; + } + + private function releaseGateFetchJson(string $baseUrl, string $path): array + { + $result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $path)); + if (($result['status'] ?? 0) !== 200) { + throw new RuntimeException(sprintf('%s returned HTTP %d.', $path, (int)($result['status'] ?? 0))); + } + if (str_contains(strtolower((string)($result['content_type'] ?? '')), 'text/html')) { + throw new RuntimeException(sprintf('%s was served as HTML.', $path)); + } + + $decoded = json_decode((string)($result['body'] ?? ''), true); + if (!is_array($decoded)) { + throw new RuntimeException(sprintf('%s did not return valid JSON.', $path)); + } + + $result['json'] = $decoded; + return $result; + } + + private function releaseGateVerifyStaticAsset(string $baseUrl, string $assetUrl, array $assetHashes): void + { + $result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $assetUrl)); + if (($result['status'] ?? 0) !== 200) { + throw new RuntimeException(sprintf('%s returned HTTP %d.', $assetUrl, (int)($result['status'] ?? 0))); + } + + $body = (string)($result['body'] ?? ''); + if ($body === '') { + throw new RuntimeException(sprintf('%s returned an empty body.', $assetUrl)); + } + if ($this->releaseGateRejectsHtml($assetUrl) && str_contains(strtolower((string)($result['content_type'] ?? '')), 'text/html')) { + throw new RuntimeException(sprintf('%s was served as HTML.', $assetUrl)); + } + + $hashKey = str_starts_with($assetUrl, '/') ? $assetUrl : '/' . ltrim($assetUrl, '/'); + if (is_array($assetHashes[$hashKey] ?? null) && !empty($assetHashes[$hashKey]['sha256'])) { + $actualHash = hash('sha256', $body); + if (!hash_equals((string)$assetHashes[$hashKey]['sha256'], $actualHash)) { + throw new RuntimeException(sprintf('%s sha256 hash mismatch.', $assetUrl)); + } + } + } + + private function releaseGateFetch(string $url): array + { + $curl = curl_init($url); + if ($curl === false) { + throw new RuntimeException('Could not initialize release gate request.'); + } + + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 5); + curl_setopt($curl, CURLOPT_TIMEOUT, 15); + curl_setopt($curl, CURLOPT_NOSIGNAL, true); + curl_setopt($curl, CURLOPT_HTTPHEADER, [ + 'Accept: application/json, text/html, */*', + 'Cache-Control: no-cache', + 'Pragma: no-cache', + 'User-Agent: Truckwash-Release-Gate', + ]); + + $body = curl_exec($curl); + $error = curl_error($curl); + $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + $contentType = (string)curl_getinfo($curl, CURLINFO_CONTENT_TYPE); + curl_close($curl); + + if ($body === false) { + throw new RuntimeException('Release gate request failed: ' . $error); + } + + return [ + 'url' => $url, + 'status' => $status, + 'content_type' => $contentType, + 'body' => (string)$body, + ]; + } + + private function releaseGateJoinUrl(string $baseUrl, string $path): string + { + if (preg_match('#^https?://#i', $path) === 1) { + return $path; + } + + return rtrim($baseUrl, '/') . '/' . ltrim($path, '/'); + } + + private function releaseGateCommitMatches(string $actual, string $expected): bool + { + $expected = strtolower(trim($expected)); + if ($expected === '') { + return true; + } + $actual = strtolower(trim($actual)); + return $actual !== '' && ($actual === $expected || str_starts_with($actual, $expected)); + } + + private function verifyReleaseDeploymentReadiness(array $deployment, array $target, string $app, string $expectedCommit, array $options = []): array + { + $app = $this->normalizeApp($app); + $baseUrl = $this->normalizeReleasePublicBaseUrl($deployment['deployment_url'] ?? null, $app) + ?? $this->releaseTargetPublicBaseUrl($target); + if ($baseUrl === null || $baseUrl === '') { + throw new RuntimeException(sprintf('%s deployment has no public URL for readiness verification.', strtoupper($app))); + } + + $timeout = max(0, min(300, (int)($options['wait_timeout_seconds'] ?? 300))); + $pollInterval = max(1, min(60, (int)($options['poll_interval_seconds'] ?? 10))); + $deadline = time() + $timeout; + $attempts = 0; + $lastMessage = 'Readiness verification did not run.'; + + do { + $attempts++; + try { + if ($app === 'frontend') { + $context = $this->releaseStaticArtifactAttempt([ + 'environment_url' => $baseUrl, + 'expected_commit' => $expectedCommit, + 'build_id' => (string)($options['build_id'] ?? ''), + 'shell_paths' => $this->releaseGateStringArray($options['shell_paths'] ?? ['/', '/guest/book/wash']), + ]); + $context['app'] = $app; + $context['base_url'] = $baseUrl; + $context['attempts'] = $attempts; + return $context; + } + + $json = $this->releaseGateFetchJson($baseUrl, 'ping'); + $payload = $json['json']; + if (array_key_exists('success', $payload) && $payload['success'] !== true) { + throw new RuntimeException('API ping returned success=false.'); + } + $data = is_array($payload['data'] ?? null) ? $payload['data'] : $payload; + $actualCommit = (string)( + $data['api_commit_sha'] + ?? $data['backend_commit_sha'] + ?? $data['commit_sha'] + ?? $data['backend_version'] + ?? '' + ); + if (!$this->releaseGateCommitMatches($actualCommit, $expectedCommit)) { + throw new RuntimeException(sprintf( + 'API ping commit %s did not match expected commit %s.', + $actualCommit !== '' ? $actualCommit : '(missing)', + $expectedCommit + )); + } + + return [ + 'app' => $app, + 'base_url' => $baseUrl, + 'path' => 'ping', + 'status' => $json['status'] ?? null, + 'commit_sha' => $actualCommit, + 'attempts' => $attempts, + ]; + } catch (Throwable $throwable) { + $lastMessage = $throwable->getMessage(); + if (time() >= $deadline) { + break; + } + sleep($pollInterval); + } + } while (time() <= $deadline); + + throw new RuntimeException(sprintf( + '%s container readiness did not match commit %s after %d attempts: %s', + strtoupper($app), + $expectedCommit, + $attempts, + $lastMessage + )); + } + + private function releaseGateRejectsHtml(string $assetUrl): bool + { + return preg_match('/\.(js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i', parse_url($assetUrl, PHP_URL_PATH) ?: '') === 1; + } + + private function releaseGateUniqueStrings(array $values): array + { + $unique = []; + foreach ($values as $value) { + $value = trim((string)$value); + if ($value !== '' && !in_array($value, $unique, true)) { + $unique[] = $value; + } + } + + return $unique; + } + + public function syncChannel(int $channelId, ?int $actorUserId = null, array $options = []): array + { + $this->ensureSchema(); + $channel = $this->getChannel($channelId); + if ((int)($channel['enabled'] ?? 0) !== 1) { + throw new RuntimeException('Release channel is disabled.'); + } + + $requestedApp = ''; + if (trim((string)($options['app'] ?? '')) !== '') { + $requestedApp = $this->normalizeApp((string)$options['app']); + } + $apps = $requestedApp !== '' ? [$requestedApp] : self::APPS; + $branch = trim((string)($options['branch'] ?? '')) ?: self::releaseBranchForChannel($channel); + $routeSlug = self::routeSlugForChannel((string)$channel['slug']); + $requestedCommitSha = self::normalizeCommitSha((string)($options['commit_sha'] ?? $options['commit'] ?? '')); + $commitMode = $requestedCommitSha !== '' ? 'specific' : 'latest'; + $requireReadiness = $this->toBool($options['require_readiness'] ?? false); + + $operationId = $this->createOperationRun('channel_sync', [ + 'subject_type' => 'channel', + 'subject_id' => (string)$channelId, + 'channel_id' => $channelId, + 'app' => $requestedApp !== '' ? $requestedApp : null, + 'title' => sprintf('Sync %s release channel', (string)($channel['name'] ?? $channel['slug'])), + 'actor_user_id' => $actorUserId, + 'context' => [ + 'channel_slug' => $channel['slug'], + 'route_slug' => $routeSlug, + 'branch' => $branch, + 'apps' => $apps, + 'source' => $options['source'] ?? 'manual', + 'repository' => $options['repository'] ?? null, + 'commit_mode' => $commitMode, + 'commit_sha' => $requestedCommitSha !== '' ? $requestedCommitSha : null, + 'workflow_url' => $options['workflow_url'] ?? $options['build_url'] ?? null, + 'auto_sync_event_id' => $options['auto_sync_event_id'] ?? null, + 'auto_sync_event' => $options['auto_sync_event'] ?? null, + 'gate_operation_id' => $options['gate_operation_id'] ?? null, + ], + ]); + + $statuses = []; + $deployments = []; + $this->recordOperationStep( + $operationId, + 'channel_mapping', + 'Channel route and branch mapping', + 'passed', + sprintf('Channel %s syncs from branch %s and publishes under /%s/{api|frontend}.', (string)$channel['slug'], $branch, $routeSlug), + null, + null, + ['channel_slug' => $channel['slug'], 'route_slug' => $routeSlug, 'branch' => $branch] + ); + $statuses[] = 'passed'; + + $this->recordOperationStep( + $operationId, + 'data_services_guard', + 'Data services guard', + 'passed', + 'Code sync will not deploy or replace MariaDB, Redis, or MinIO.', + null, + null, + ['data_services' => $this->channelDataServicesSummary($channel)] + ); + $statuses[] = 'passed'; + + if ($this->channelUsesProductionServices($channel)) { + $serviceChannel = $this->productionServiceChannel(); + $this->recordOperationStep( + $operationId, + 'production_services', + 'Production frontend and API services', + 'passed', + sprintf( + '%s uses the %s production frontend and API services; channel sync does not deploy separate release services.', + (string)($channel['name'] ?? $channel['slug']), + (string)($serviceChannel['name'] ?? $serviceChannel['slug']) + ), + null, + null, + [ + 'channel_slug' => (string)($channel['slug'] ?? ''), + 'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''), + 'service_policy' => self::PRODUCTION_SERVICE_POLICY, + ] + ); + $statuses[] = 'passed'; + + $this->completeOperationRun( + $operationId, + 'passed', + 'Channel sync completed; production services remain active.', + null + ); + $operation = $this->operationDetail($operationId); + $operation['deployments'] = []; + $operation['channel'] = $this->publicChannel($this->getChannel($channelId)); + return $operation; + } + + foreach ($apps as $app) { + $target = $this->deploymentTargetForChannelApp($channelId, $app); + if ($target === null) { + $this->recordOperationStep( + $operationId, + $app . '_target', + strtoupper($app) . ' Coolify target', + 'failed', + 'No Coolify deployment target exists for this channel/app.', + 'The channel cannot receive a new ' . $app . ' deployment.', + 'Create the missing deployment target and use Retry.', + ['channel_id' => $channelId, 'channel_slug' => $channel['slug'], 'app' => $app, 'retry_action' => 'configure_target'] + ); + $statuses[] = 'failed'; + continue; + } + $target = $this->prepareChannelSyncApplicationTarget($target, $actorUserId); + if (($target['_release_auto_prepared_application'] ?? false) === true) { + $this->recordOperationStep( + $operationId, + $app . '_target_prepared', + strtoupper($app) . ' Coolify application target', + 'passed', + sprintf('%s target was prepared to create a path-routed Coolify application.', strtoupper($app)), + null, + null, + ['target_id' => (int)$target['id'], 'app' => $app] + ); + $statuses[] = 'passed'; + } + + $repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app))); + $requestedRepository = self::normalizeGithubRepositoryName((string)($options['repository'] ?? '')); + if ($requestedRepository !== '') { + $repository = $requestedRepository; + } + if ($repository === '') { + $repository = self::defaultRepositoryForApp($app); + } + $access = $this->githubRepositoryAccess([ + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $requestedCommitSha, + 'commit_mode' => $commitMode, + ]); + if (!($access['ok'] ?? false)) { + $this->recordOperationStep( + $operationId, + $app . '_branch', + strtoupper($app) . ' branch', + 'warning', + sprintf('%s branch %s was not deployed because it could not be verified.', strtoupper($app), $branch), + (string)($access['message'] ?? 'GitHub branch access failed.'), + 'Create the branch from master or repair GitHub access, then use Retry.', + [ + 'channel_slug' => $channel['slug'], + 'route_slug' => $routeSlug, + 'app' => $app, + 'repository' => $repository, + 'branch' => $branch, + 'github_access' => $access, + 'retry_action' => 'retry_sync', + ] + ); + $statuses[] = 'warning'; + continue; + } + + $commitSha = trim((string)($access['commit_sha'] ?? $access['latest_commit_sha'] ?? '')); + $current = $this->currentDeploymentForChannelApp($channelId, $app); + if ($commitSha !== '' && $current !== null && trim((string)($current['commit_sha'] ?? '')) === $commitSha) { + $this->recordOperationStep( + $operationId, + $app . '_already_current', + strtoupper($app) . ' deployment', + 'skipped', + sprintf('%s is already active at %s.', strtoupper($app), substr($commitSha, 0, 12)), + null, + null, + ['deployment' => $this->publicDeployment($current), 'github_access' => $access] + ); + $statuses[] = 'skipped'; + continue; + } + + try { + $deployment = $this->startDeployment([ + 'target_id' => (int)$target['id'], + 'channel_id' => $channelId, + 'app' => $app, + 'repository' => $repository, + 'branch' => $branch, + 'commit_mode' => $commitMode, + 'commit_sha' => $commitSha, + 'version_label' => $commitSha !== '' ? substr($commitSha, 0, 12) : date('Ymd-His'), + 'build_url' => $options['build_url'] ?? null, + 'metadata' => [ + 'release_operation_id' => $operationId, + 'sync_source' => $options['source'] ?? 'manual', + 'webhook_commit_sha' => $options['commit_sha'] ?? null, + 'auto_sync_event_id' => $options['auto_sync_event_id'] ?? null, + 'gate_operation_id' => $options['gate_operation_id'] ?? null, + 'workflow_url' => $options['workflow_url'] ?? null, + ], + ], $actorUserId); + if (($deployment['status'] ?? '') === 'deployed' && !empty($deployment['id'])) { + if ($requireReadiness) { + $readiness = $this->verifyReleaseDeploymentReadiness($deployment, $target, $app, $commitSha, $options); + $this->recordOperationStep( + $operationId, + $app . '_readiness', + strtoupper($app) . ' container readiness', + 'passed', + sprintf('%s container readiness matched commit %s.', strtoupper($app), substr($commitSha, 0, 12)), + null, + null, + $readiness + ); + } + $promoted = $this->promoteDeployment((int)$deployment['id'], $actorUserId); + $deployment = $promoted['deployment'] ?? $deployment; + } + $deployments[] = $deployment; + $this->recordOperationStep( + $operationId, + $app . '_deploy', + strtoupper($app) . ' deploy and activate', + (($deployment['status'] ?? '') === 'failed') ? 'failed' : 'passed', + (($deployment['status'] ?? '') === 'failed') + ? sprintf('%s deployment failed before activation.', strtoupper($app)) + : sprintf('%s deployment was recorded and the latest deployed revision is active for this channel.', strtoupper($app)), + (($deployment['status'] ?? '') === 'failed') ? (string)($deployment['error_message'] ?? 'Deployment failed.') : null, + (($deployment['status'] ?? '') === 'failed') ? 'Open the deployment result diagnostics, fix the provider error, then use Retry.' : null, + ['deployment' => $deployment, 'github_access' => $access] + ); + $statuses[] = (($deployment['status'] ?? '') === 'failed') ? 'failed' : 'passed'; + } catch (Throwable $throwable) { + $this->recordOperationStep( + $operationId, + $app . '_deploy', + strtoupper($app) . ' deploy and activate', + 'failed', + sprintf('%s deployment failed before activation.', strtoupper($app)), + $throwable->getMessage(), + 'Review the deployment target, Coolify service, and GitHub branch, then use Retry.', + ['app' => $app, 'repository' => $repository, 'branch' => $branch] + ); + $statuses[] = 'failed'; + } + } + + $finalStatus = in_array('failed', $statuses, true) + ? 'failed' + : (count(array_intersect($statuses, ['warning'])) > 0 ? 'warning' : 'passed'); + $this->completeOperationRun( + $operationId, + $finalStatus, + $finalStatus === 'passed' + ? 'Channel sync completed.' + : 'Channel sync finished with failures or warnings. Open the operation steps for exact diagnostics.', + $finalStatus === 'passed' ? null : 'Use the step retry action after fixing the reported target, branch, or provider issue.' + ); + + $operation = $this->operationDetail($operationId); + $operation['deployments'] = $deployments; + $operation['channel'] = $this->publicChannel($this->getChannel($channelId)); + return $operation; + } + + private function processReleaseGateAutoSync(array $gateInput, ?array $channel, int $gateOperationId, ?int $actorUserId): array + { + if ($channel === null) { + throw new RuntimeException('Automatic container update requires a release channel.'); + } + + $channelId = (int)$channel['id']; + $app = $this->normalizeApp((string)($gateInput['app'] ?? '')); + $commitSha = self::normalizeCommitSha((string)($gateInput['expected_commit'] ?? '')); + if ($commitSha === '') { + throw new RuntimeException('Automatic container update requires a 7-40 character Git commit SHA.'); + } + + $branch = trim((string)($gateInput['branch'] ?? '')) ?: self::releaseBranchForChannel($channel); + $repository = self::normalizeGithubRepositoryName((string)($gateInput['repository'] ?? '')); + if ($repository === '') { + $repository = self::defaultRepositoryForApp($app); + } + + $target = $this->deploymentTargetForChannelApp($channelId, $app); + if ($target === null) { + throw new RuntimeException(sprintf('No %s deployment target is configured for %s.', strtoupper($app), (string)$channel['slug'])); + } + if (!$this->toBool($target['auto_deploy'] ?? false)) { + throw new RuntimeException(sprintf('%s automatic deployments are disabled for %s.', strtoupper($app), (string)$channel['slug'])); + } + + $targetRepository = self::normalizeGithubRepositoryName((string)($target['repository'] ?? '')); + $targetBranch = trim((string)($target['branch'] ?? '')); + if ($targetRepository !== '' && $repository !== $targetRepository) { + throw new RuntimeException(sprintf('Gate repository %s does not match target repository %s.', $repository, $targetRepository)); + } + if ($targetBranch !== '' && $branch !== $targetBranch) { + throw new RuntimeException(sprintf('Gate branch %s does not match target branch %s.', $branch, $targetBranch)); + } + + $event = $this->upsertReleaseAutoSyncEvent([ + 'channel_id' => $channelId, + 'app' => $app, + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'status' => 'gate_passed', + 'source' => 'release_gate', + 'workflow_url' => $gateInput['workflow_url'] ?? null, + 'gate_operation_id' => $gateOperationId, + 'metadata' => [ + 'release_gate' => $gateInput, + ], + ]); + + $eventId = (int)$event['id']; + if (!$this->acquireReleaseAutoSyncLock($eventId)) { + return [ + 'step_status' => 'passed', + 'message' => 'Automatic container update is already being processed for this commit.', + 'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event), + ]; + } + + try { + $event = $this->releaseAutoSyncEventById($eventId) ?? $event; + if (in_array((string)($event['status'] ?? ''), ['promoted', 'deployed'], true)) { + return [ + 'step_status' => 'passed', + 'message' => 'Automatic container update was already completed for this commit.', + 'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event), + ]; + } + + $current = $this->currentDeploymentForChannelApp($channelId, $app); + if ($current !== null && $this->releaseGateCommitMatches((string)($current['commit_sha'] ?? ''), $commitSha)) { + $event = $this->updateReleaseAutoSyncEvent($eventId, 'promoted', [ + 'deployment_id' => (int)$current['id'], + 'metadata' => ['already_current' => true], + ]); + return [ + 'step_status' => 'passed', + 'message' => sprintf('%s is already active at %s.', strtoupper($app), substr($commitSha, 0, 12)), + 'deployment' => $this->publicDeployment($current), + 'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event), + ]; + } + + $event = $this->updateReleaseAutoSyncEvent($eventId, 'syncing'); + $operation = $this->syncChannel($channelId, $actorUserId, [ + 'app' => $app, + 'source' => 'release_gate', + 'repository' => $repository, + 'branch' => $branch, + 'commit_mode' => 'specific', + 'commit_sha' => $commitSha, + 'build_url' => $gateInput['workflow_url'] ?? null, + 'workflow_url' => $gateInput['workflow_url'] ?? null, + 'gate_operation_id' => $gateOperationId, + 'auto_sync_event_id' => $eventId, + 'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event), + 'require_readiness' => true, + 'wait_timeout_seconds' => $gateInput['wait_timeout_seconds'] ?? 300, + 'poll_interval_seconds' => $gateInput['poll_interval_seconds'] ?? 10, + 'build_id' => $gateInput['build_id'] ?? '', + 'shell_paths' => $gateInput['shell_paths'] ?? [], + ]); + + if ((string)($operation['status'] ?? '') !== 'passed') { + throw new RuntimeException((string)($operation['summary'] ?? 'Automatic channel sync did not pass.')); + } + + $deployment = $this->currentDeploymentForChannelApp($channelId, $app); + if ($deployment === null || !$this->releaseGateCommitMatches((string)($deployment['commit_sha'] ?? ''), $commitSha)) { + throw new RuntimeException(sprintf('%s was deployed but was not promoted as the active %s release.', strtoupper($app), (string)$channel['slug'])); + } + + $event = $this->updateReleaseAutoSyncEvent($eventId, 'promoted', [ + 'sync_operation_id' => (int)($operation['id'] ?? 0) ?: null, + 'deployment_id' => (int)$deployment['id'], + 'metadata' => [ + 'sync_operation_id' => $operation['id'] ?? null, + 'deployment_id' => $deployment['id'] ?? null, + ], + ]); + + return [ + 'step_status' => 'passed', + 'message' => sprintf('%s container was deployed and promoted at %s.', strtoupper($app), substr($commitSha, 0, 12)), + 'sync_operation' => $operation, + 'deployment' => $this->publicDeployment($deployment), + 'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event), + ]; + } catch (Throwable $throwable) { + $this->updateReleaseAutoSyncEvent($eventId, 'failed', [ + 'error_message' => $throwable->getMessage(), + ]); + throw $throwable; + } finally { + $this->releaseReleaseAutoSyncLock($eventId); + } + } + + private function upsertReleaseAutoSyncEvent(array $input): array + { + $channelId = (int)$input['channel_id']; + $app = $this->normalizeApp((string)$input['app']); + $repository = self::normalizeGithubRepositoryName((string)$input['repository']); + $branch = trim((string)$input['branch']); + $commitSha = self::normalizeCommitSha((string)$input['commit_sha']); + $status = self::safeIdentifier((string)($input['status'] ?? 'pending'), 32) ?: 'pending'; + $source = self::safeIdentifier((string)($input['source'] ?? ''), 64) ?: null; + $workflowUrl = $this->nullableString($input['workflow_url'] ?? null, 512); + $gateOperationId = $this->nullablePositiveInt($input['gate_operation_id'] ?? null); + $metadata = is_array($input['metadata'] ?? null) ? $input['metadata'] : []; + + if ($channelId <= 0 || $repository === '' || $branch === '' || $commitSha === '') { + throw new RuntimeException('Automatic sync event requires channel, app, repository, branch, and commit.'); + } + + $existing = $this->releaseAutoSyncEventFor($channelId, $app, $repository, $branch, $commitSha); + if ($existing === null) { + $this->execute( + "INSERT INTO release_auto_sync_events ( + channel_id, app, repository, branch, commit_sha, status, source, + workflow_url, gate_operation_id, metadata_json, gate_passed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = 'gate_passed' THEN NOW() ELSE NULL END)", + 'isssssssiss', + [ + $channelId, + $app, + $repository, + $branch, + $commitSha, + $status, + $source, + $workflowUrl, + $gateOperationId, + self::jsonEncode(self::redactPayload($metadata)), + $status, + ] + ); + return $this->releaseAutoSyncEventById($this->insertId()) ?? []; + } + + $existingStatus = (string)($existing['status'] ?? 'pending'); + if ($status === 'pending' && !in_array($existingStatus, ['pending', 'failed'], true)) { + $status = $existingStatus; + } + if ($status === 'gate_passed' && in_array($existingStatus, ['syncing', 'promoted', 'deployed'], true)) { + $status = $existingStatus; + } + + $this->execute( + "UPDATE release_auto_sync_events + SET status = ?, + source = COALESCE(?, source), + workflow_url = COALESCE(?, workflow_url), + gate_operation_id = COALESCE(NULLIF(?, 0), gate_operation_id), + error_message = NULL, + metadata_json = ?, + gate_passed_at = CASE WHEN ? = 'gate_passed' THEN COALESCE(gate_passed_at, NOW()) ELSE gate_passed_at END, + updated_at = NOW() + WHERE id = ?", + 'sssissi', + [ + $status, + $source, + $workflowUrl, + $gateOperationId ?? 0, + self::jsonEncode(self::redactPayload($metadata)), + $status, + (int)$existing['id'], + ] + ); + + return $this->releaseAutoSyncEventById((int)$existing['id']) ?? []; + } + + private function updateReleaseAutoSyncEvent(int $id, string $status, array $input = []): array + { + $status = self::safeIdentifier($status, 32) ?: 'pending'; + $syncOperationId = $this->nullablePositiveInt($input['sync_operation_id'] ?? null); + $deploymentId = $this->nullablePositiveInt($input['deployment_id'] ?? null); + $errorMessage = isset($input['error_message']) ? substr((string)$input['error_message'], 0, 4096) : null; + $metadata = is_array($input['metadata'] ?? null) ? self::jsonEncode(self::redactPayload($input['metadata'])) : null; + $this->execute( + "UPDATE release_auto_sync_events + SET status = ?, + sync_operation_id = COALESCE(NULLIF(?, 0), sync_operation_id), + deployment_id = COALESCE(NULLIF(?, 0), deployment_id), + error_message = ?, + metadata_json = COALESCE(?, metadata_json), + synced_at = CASE WHEN ? IN ('deployed', 'promoted') THEN COALESCE(synced_at, NOW()) ELSE synced_at END, + promoted_at = CASE WHEN ? = 'promoted' THEN COALESCE(promoted_at, NOW()) ELSE promoted_at END, + failed_at = CASE WHEN ? = 'failed' THEN NOW() ELSE failed_at END, + updated_at = NOW() + WHERE id = ?", + 'siisssssi', + [$status, $syncOperationId ?? 0, $deploymentId ?? 0, $errorMessage, $metadata, $status, $status, $status, $id] + ); + + return $this->releaseAutoSyncEventById($id) ?? []; + } + + private function releaseAutoSyncEventFor(int $channelId, string $app, string $repository, string $branch, string $commitSha): ?array + { + return $this->selectOne( + "SELECT e.*, c.slug AS channel_slug, c.name AS channel_name + FROM release_auto_sync_events e + INNER JOIN release_channels c ON c.id = e.channel_id + WHERE e.channel_id = ? AND e.app = ? AND e.repository = ? AND e.branch = ? AND e.commit_sha = ? + LIMIT 1", + 'issss', + [$channelId, $app, $repository, $branch, $commitSha] + ); + } + + private function releaseAutoSyncEventById(int $id): ?array + { + return $this->selectOne( + "SELECT e.*, c.slug AS channel_slug, c.name AS channel_name + FROM release_auto_sync_events e + INNER JOIN release_channels c ON c.id = e.channel_id + WHERE e.id = ? + LIMIT 1", + 'i', + [$id] + ); + } + + private function publicReleaseAutoSyncEvent(array $event): array + { + return [ + 'id' => (int)($event['id'] ?? 0), + 'channel_id' => (int)($event['channel_id'] ?? 0), + 'channel_slug' => $event['channel_slug'] ?? null, + 'channel_name' => $event['channel_name'] ?? null, + 'app' => (string)($event['app'] ?? ''), + 'repository' => (string)($event['repository'] ?? ''), + 'branch' => (string)($event['branch'] ?? ''), + 'commit_sha' => (string)($event['commit_sha'] ?? ''), + 'status' => (string)($event['status'] ?? 'unknown'), + 'source' => $event['source'] ?? null, + 'workflow_url' => $event['workflow_url'] ?? null, + 'gate_operation_id' => isset($event['gate_operation_id']) ? (int)$event['gate_operation_id'] : null, + 'sync_operation_id' => isset($event['sync_operation_id']) ? (int)$event['sync_operation_id'] : null, + 'deployment_id' => isset($event['deployment_id']) ? (int)$event['deployment_id'] : null, + 'error_message' => $event['error_message'] ?? null, + 'metadata' => self::jsonDecode($event['metadata_json'] ?? null), + 'received_at' => $event['received_at'] ?? null, + 'gate_passed_at' => $event['gate_passed_at'] ?? null, + 'synced_at' => $event['synced_at'] ?? null, + 'promoted_at' => $event['promoted_at'] ?? null, + 'failed_at' => $event['failed_at'] ?? null, + ]; + } + + private function acquireReleaseAutoSyncLock(int $eventId): bool + { + $lockName = 'release_auto_sync:' . $eventId; + $row = $this->selectOne('SELECT GET_LOCK(?, 0) AS acquired', 's', [$lockName]); + return (int)($row['acquired'] ?? 0) === 1; + } + + private function releaseReleaseAutoSyncLock(int $eventId): void + { + try { + $this->selectOne('SELECT RELEASE_LOCK(?) AS released', 's', ['release_auto_sync:' . $eventId]); + } catch (Throwable) { + } + } + + public function runIssueAction(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + + $issueKey = trim((string)($input['issue_key'] ?? $input['key'] ?? '')); + $actionId = self::safeIdentifier((string)($input['action_id'] ?? $input['action'] ?? ''), 64); + $actionInputs = is_array($input['inputs'] ?? null) ? $input['inputs'] : []; + $confirmed = $this->toBool($input['confirm'] ?? false); + $summary = $this->summary(); + + $issue = $this->releaseStatusIssueByKey($summary, $issueKey); + if ($issue === null) { + $this->audit(null, null, 'release_issue_action_attempted', $actorUserId, 'warning', [ + 'issue_key' => $issueKey, + 'action_id' => $actionId, + 'inputs' => $actionInputs, + 'status' => 'stale_issue', + ]); + + return [ + 'status' => 'failed', + 'message' => 'This release issue is no longer active. Refresh Release Manager and review the current state.', + 'result' => null, + 'summary' => $summary, + ]; + } + + $action = $this->releaseStatusActionById($issue, $actionId); + if ($action === null) { + $this->audit( + $this->nullablePositiveInt($issue['channel_id'] ?? null), + $this->nullablePositiveInt($issue['deployment_id'] ?? null), + 'release_issue_action_attempted', + $actorUserId, + 'warning', + [ + 'issue_key' => $issueKey, + 'issue' => $issue, + 'action_id' => $actionId, + 'inputs' => $actionInputs, + 'status' => 'unavailable_action', + ] + ); + + return [ + 'status' => 'failed', + 'message' => 'This release issue action is no longer available.', + 'issue' => $issue, + 'result' => null, + 'summary' => $summary, + ]; + } + + if (trim((string)($action['disabled_reason'] ?? '')) !== '') { + $this->audit( + $this->nullablePositiveInt($issue['channel_id'] ?? null), + $this->nullablePositiveInt($issue['deployment_id'] ?? null), + 'release_issue_action_attempted', + $actorUserId, + 'warning', + [ + 'issue_key' => $issueKey, + 'issue' => $issue, + 'action_id' => $actionId, + 'inputs' => $actionInputs, + 'status' => 'disabled', + 'disabled_reason' => (string)$action['disabled_reason'], + ] + ); + + return [ + 'status' => 'needs_input', + 'message' => (string)$action['disabled_reason'], + 'issue' => $issue, + 'action' => $action, + 'result' => null, + 'summary' => $summary, + ]; + } + + if (($action['requires_confirmation'] ?? false) && !$confirmed) { + $this->audit( + $this->nullablePositiveInt($issue['channel_id'] ?? null), + $this->nullablePositiveInt($issue['deployment_id'] ?? null), + 'release_issue_action_attempted', + $actorUserId, + 'warning', + [ + 'issue_key' => $issueKey, + 'issue' => $issue, + 'action_id' => $actionId, + 'inputs' => $actionInputs, + 'status' => 'confirmation_required', + ] + ); + + return [ + 'status' => 'needs_input', + 'message' => 'Confirm this release issue action before Release Manager changes deployment state.', + 'issue' => $issue, + 'action' => $action, + 'result' => null, + 'summary' => $summary, + ]; + } + + $this->audit( + $this->nullablePositiveInt($issue['channel_id'] ?? null), + $this->nullablePositiveInt($issue['deployment_id'] ?? null), + 'release_issue_action_attempted', + $actorUserId, + 'info', + [ + 'issue_key' => $issueKey, + 'issue' => $issue, + 'action_id' => $actionId, + 'inputs' => $actionInputs, + ] + ); + + try { + $result = $this->executeReleaseIssueAction($issue, $actionId, $actionInputs, $actorUserId); + $status = (string)($result['status'] ?? 'completed'); + $message = (string)($result['message'] ?? 'Release issue action completed.'); + $this->audit( + $this->nullablePositiveInt($issue['channel_id'] ?? null), + $this->nullablePositiveInt($issue['deployment_id'] ?? null), + 'release_issue_action_completed', + $actorUserId, + $status === 'failed' ? 'error' : 'info', + [ + 'issue_key' => $issueKey, + 'action_id' => $actionId, + 'status' => $status, + 'result' => $result['result'] ?? null, + ] + ); + + return [ + 'status' => $status, + 'message' => $message, + 'issue' => $issue, + 'action' => $action, + 'result' => $result['result'] ?? null, + 'summary' => $this->summary(), + ]; + } catch (Throwable $throwable) { + $this->audit( + $this->nullablePositiveInt($issue['channel_id'] ?? null), + $this->nullablePositiveInt($issue['deployment_id'] ?? null), + 'release_issue_action_failed', + $actorUserId, + 'error', + [ + 'issue_key' => $issueKey, + 'action_id' => $actionId, + 'error' => $throwable->getMessage(), + ] + ); + + return [ + 'status' => 'failed', + 'message' => $throwable->getMessage(), + 'issue' => $issue, + 'action' => $action, + 'result' => null, + 'summary' => $this->summary(), + ]; + } + } + + private function executeReleaseIssueAction(array $issue, string $actionId, array $inputs, ?int $actorUserId): array + { + return match ($actionId) { + 'retry_deployment' => $this->retryReleaseIssueDeployment($issue, $inputs, $actorUserId), + 'deploy_missing_version' => $this->deployReleaseIssueMissingVersion($issue, $inputs, $actorUserId), + 'set_bundle' => $this->setReleaseIssueBundle($issue, $inputs, $actorUserId), + 'complete_data_services' => $this->completeReleaseIssueDataServices($issue, $inputs, $actorUserId), + 'reconcile_coolify_target' => $this->runReleaseIssueCoolifyTargetAction($issue, 'reconcile', $actorUserId), + 'redeploy_coolify_target' => $this->runReleaseIssueCoolifyTargetAction($issue, 'deploy', $actorUserId), + 'restart_coolify_target' => $this->runReleaseIssueCoolifyTargetAction($issue, 'restart', $actorUserId), + 'prepare_application_target' => $this->prepareReleaseIssueApplicationTarget($issue, $actorUserId), + 'refresh_status' => [ + 'status' => 'completed', + 'message' => 'Release status refreshed.', + 'result' => null, + ], + default => throw new RuntimeException('Unknown release issue action.'), + }; + } + + private function retryReleaseIssueDeployment(array $issue, array $inputs, ?int $actorUserId): array + { + $deploymentId = $this->nullablePositiveInt($issue['deployment_id'] ?? null); + if ($deploymentId === null) { + throw new RuntimeException('The failed deployment record is missing.'); + } + + $deployment = $this->getDeployment($deploymentId); + $payload = self::jsonDecode($deployment['requested_payload_json'] ?? null); + $payload = is_array($payload) ? $payload : []; + foreach (['version_id'] as $key) { + if (array_key_exists($key, $inputs)) { + $payload[$key] = $inputs[$key]; + } + } + $payload = array_replace($payload, [ + 'channel_id' => (int)$deployment['channel_id'], + 'target_id' => $this->nullablePositiveInt($deployment['target_id'] ?? null), + 'app' => (string)$deployment['app'], + 'repository' => (string)($deployment['repository'] ?? ''), + 'branch' => (string)($deployment['branch'] ?? self::DEFAULT_BRANCH), + 'commit_mode' => trim((string)($deployment['commit_sha'] ?? '')) !== '' ? 'specific' : 'latest', + 'commit_sha' => (string)($deployment['commit_sha'] ?? ''), + 'service_set_id' => $this->nullablePositiveInt($deployment['service_set_id'] ?? null), + 'bundle_id' => $this->nullablePositiveInt($deployment['bundle_id'] ?? null), + 'deployment_kind' => (string)($deployment['deployment_kind'] ?? 'single_app'), + ]); + + $newDeployment = $this->startDeployment($payload, $actorUserId); + $status = strtolower((string)($newDeployment['status'] ?? '')); + return [ + 'status' => $status === 'failed' ? 'failed' : (in_array($status, ['queued', 'deploying'], true) ? 'queued' : 'completed'), + 'message' => $status === 'failed' ? 'Deployment retry failed.' : 'Deployment retry started.', + 'result' => ['deployment' => $newDeployment], + ]; + } + + private function deployReleaseIssueMissingVersion(array $issue, array $inputs, ?int $actorUserId): array + { + $targetId = $this->nullablePositiveInt($inputs['target_id'] ?? $issue['target_id'] ?? null); + if ($targetId === null) { + return [ + 'status' => 'needs_input', + 'message' => 'Select or create a deployment target before deploying the missing version.', + 'result' => [ + 'required_inputs' => ['target_id'], + 'channel_id' => $issue['channel_id'] ?? null, + 'app' => $issue['service_key'] ?? null, + ], + ]; + } + + $target = $this->getDeploymentTarget($targetId); + $deployment = $this->startDeployment([ + 'channel_id' => (int)$target['channel_id'], + 'target_id' => $targetId, + 'app' => (string)$target['app'], + 'repository' => (string)$target['repository'], + 'branch' => (string)$target['branch'], + 'commit_mode' => (string)($inputs['commit_mode'] ?? 'latest'), + 'commit_sha' => (string)($inputs['commit_sha'] ?? ''), + 'version_label' => (string)($inputs['version_label'] ?? ''), + ], $actorUserId); + + $status = strtolower((string)($deployment['status'] ?? '')); + return [ + 'status' => $status === 'failed' ? 'failed' : (in_array($status, ['queued', 'deploying'], true) ? 'queued' : 'completed'), + 'message' => $status === 'failed' ? 'Missing version deployment failed.' : 'Missing version deployment started.', + 'result' => ['deployment' => $deployment], + ]; + } + + private function setReleaseIssueBundle(array $issue, array $inputs, ?int $actorUserId): array + { + $channelId = $this->nullablePositiveInt($issue['channel_id'] ?? null); + if ($channelId === null) { + throw new RuntimeException('Release channel is missing.'); + } + + $bundleId = $this->nullablePositiveInt($inputs['bundle_id'] ?? null); + if ($bundleId === null) { + $eligible = self::releaseStatusEligibleBundles(array_filter( + $this->listBundles(250), + static fn(array $bundle): bool => (int)($bundle['channel_id'] ?? 0) === $channelId + )); + if (count($eligible) !== 1) { + return [ + 'status' => 'needs_input', + 'message' => $eligible === [] ? 'No deployed bundle is available for this channel.' : 'Choose which deployed bundle to set.', + 'result' => [ + 'required_inputs' => ['bundle_id'], + 'bundle_choices' => $eligible, + ], + ]; + } + $bundleId = (int)$eligible[0]['id']; + } + + return [ + 'status' => 'completed', + 'message' => 'Release bundle set for channel.', + 'result' => $this->setChannelBundle($channelId, ['bundle_id' => $bundleId], $actorUserId), + ]; + } + + private function completeReleaseIssueDataServices(array $issue, array $inputs, ?int $actorUserId): array + { + $serviceSetId = $this->nullablePositiveInt($inputs['service_set_id'] ?? $issue['service_set_id'] ?? null); + if ($serviceSetId === null) { + return [ + 'status' => 'needs_input', + 'message' => 'Select the isolated service set before creating missing data services.', + 'result' => ['required_inputs' => ['service_set_id']], + ]; + } + + return [ + 'status' => 'queued', + 'message' => 'Missing isolated data services were requested.', + 'result' => $this->completeIsolatedStackDataServices($serviceSetId, ['deploy_data_targets' => true], $actorUserId), + ]; + } + + private function runReleaseIssueCoolifyTargetAction(array $issue, string $operation, ?int $actorUserId): array + { + $targetId = $this->nullablePositiveInt($issue['coolify_target_id'] ?? null); + if ($targetId === null) { + throw new RuntimeException('Coolify target is missing.'); + } + if (!class_exists(coolify_manager::class) && function_exists('app_require')) { + app_require('classes/coolify_manager.php'); + } + if (!class_exists(coolify_manager::class)) { + throw new RuntimeException('Coolify manager is not available.'); + } + + $manager = new coolify_manager(); + $result = match ($operation) { + 'reconcile' => $manager->reconcileTarget($targetId, $actorUserId), + 'deploy' => $manager->deployTarget($targetId, $actorUserId), + 'restart' => $manager->restartTarget($targetId, $actorUserId), + default => throw new RuntimeException('Unknown Coolify target action.'), + }; + + return [ + 'status' => 'queued', + 'message' => 'Coolify target action requested.', + 'result' => $result, + ]; + } + + private function prepareReleaseIssueApplicationTarget(array $issue, ?int $actorUserId): array + { + $targetId = $this->nullablePositiveInt($issue['target_id'] ?? null); + if ($targetId === null) { + throw new RuntimeException('Release deployment target is missing.'); + } + + return $this->prepareDeploymentTargetAsApplication($targetId, $actorUserId, 'warning'); + } + + private function prepareDeploymentTargetAsApplication(int $targetId, ?int $actorUserId, string $severity = 'warning'): array + { + $target = $this->getDeploymentTarget($targetId); + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + $context = is_array($context) ? $context : []; + $context['coolify_resource_type'] = 'application'; + $context['coolify_auto_create'] = true; + $context['coolify_enable_ssl'] = $this->toBool($context['coolify_enable_ssl'] ?? true); + $replacedLegacyUuid = trim((string)($target['coolify_service_uuid'] ?? '')) !== ''; + + $this->execute( + 'UPDATE release_deployment_targets SET coolify_service_uuid = NULL, deploy_context_json = ? WHERE id = ?', + 'si', + [self::jsonEncode($context), $targetId] + ); + $this->audit((int)$target['channel_id'], null, 'deployment_target_prepared_as_application', $actorUserId, $severity, [ + 'target_id' => $targetId, + 'replaced_legacy_service_uuid' => $replacedLegacyUuid, + 'severity' => $severity, + ]); + + return [ + 'status' => 'completed', + 'message' => 'Deployment target will create a Coolify application on the next deployment.', + 'result' => $this->publicDeploymentTarget($this->getDeploymentTarget($targetId)), + ]; + } + + private function prepareChannelSyncApplicationTarget(array $target, ?int $actorUserId): array + { + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + $context = is_array($context) ? $context : []; + if (!$this->releaseTargetNeedsApplicationAutoCreate($target, $context)) { + return $target; + } + + $this->prepareDeploymentTargetAsApplication((int)$target['id'], $actorUserId, 'info'); + $prepared = $this->getDeploymentTarget((int)$target['id']); + $prepared['_release_auto_prepared_application'] = true; + return $prepared; + } + + private function releaseStatusIssueByKey(array $summary, string $issueKey): ?array + { + foreach (is_array($summary['status_overview']['issues'] ?? null) ? $summary['status_overview']['issues'] : [] as $issue) { + if (is_array($issue) && (string)($issue['key'] ?? '') === $issueKey) { + return $issue; + } + } + return null; + } + + private function releaseStatusActionById(array $issue, string $actionId): ?array + { + foreach (is_array($issue['actions'] ?? null) ? $issue['actions'] : [] as $action) { + if (is_array($action) && (string)($action['id'] ?? '') === $actionId) { + return $action; + } + } + return null; + } + + private function releaseStatusOverview(array $summary): array + { + $channels = array_values(array_filter( + is_array($summary['channels'] ?? null) ? $summary['channels'] : [], + static fn(mixed $channel): bool => is_array($channel) + )); + $targetsByChannelApp = $this->releaseStatusTargetsByChannelApp( + is_array($summary['deployment_targets'] ?? null) ? $summary['deployment_targets'] : [] + ); + $deployments = array_values(array_filter( + is_array($summary['deployments'] ?? null) ? $summary['deployments'] : [], + static fn(mixed $deployment): bool => is_array($deployment) + )); + $deploymentsByChannelApp = $this->releaseStatusLatestDeploymentsByChannelApp($deployments); + $serviceSetsByChannel = $this->releaseStatusServiceSetsByChannel( + is_array($summary['service_sets'] ?? null) ? $summary['service_sets'] : [] + ); + $bundlesByChannel = $this->releaseStatusBundlesByChannel( + is_array($summary['bundles'] ?? null) ? $summary['bundles'] : [] + ); + $productionServiceChannel = $this->releaseStatusProductionServiceChannel($channels); + + $channelRows = []; + $issues = []; + foreach ($channels as $channel) { + $row = $this->releaseStatusChannelRow( + $channel, + $productionServiceChannel, + $targetsByChannelApp, + $deployments, + $deploymentsByChannelApp, + $serviceSetsByChannel, + $bundlesByChannel + ); + $channelRows[] = $row; + foreach ($row['issues'] as $issue) { + $issues[] = $issue; + } + } + + usort($issues, static function (array $a, array $b): int { + $rank = self::releaseStatusSeverityRank($b['severity'] ?? 'ok') + <=> self::releaseStatusSeverityRank($a['severity'] ?? 'ok'); + if ($rank !== 0) { + return $rank; + } + return strcmp((string)($a['channel_slug'] ?? ''), (string)($b['channel_slug'] ?? '')); + }); + + $affectedChannels = []; + $serviceCount = 0; + $unhealthyServiceCount = 0; + $missingValueCount = 0; + $criticalCount = 0; + $warningCount = 0; + foreach ($channelRows as $row) { + foreach ($row['services'] as $service) { + $serviceCount++; + if (self::releaseStatusSeverityRank($service['severity'] ?? 'ok') > 0) { + $unhealthyServiceCount++; + } + } + } + foreach ($issues as $issue) { + $severity = (string)($issue['severity'] ?? 'ok'); + if ($severity === 'critical') { + $criticalCount++; + } elseif ($severity === 'warning') { + $warningCount++; + } + if (($issue['type'] ?? '') === 'missing_value') { + $missingValueCount++; + } + if (self::releaseStatusSeverityRank($severity) > 0 && !empty($issue['channel_slug'])) { + $affectedChannels[(string)$issue['channel_slug']] = true; + } + } + + return [ + 'generated_at' => $summary['generated_at'] ?? date('c'), + 'state' => $criticalCount > 0 ? 'blocked' : ($warningCount > 0 ? 'attention' : 'ready'), + 'totals' => [ + 'channels' => count($channelRows), + 'ready_channels' => count(array_filter( + $channelRows, + static fn(array $row): bool => ($row['readiness'] ?? '') === 'ready' + )), + 'affected_channels' => count($affectedChannels), + 'issues' => count($issues), + 'critical' => $criticalCount, + 'warning' => $warningCount, + 'services' => $serviceCount, + 'unhealthy_services' => $unhealthyServiceCount, + 'missing_values' => $missingValueCount, + ], + 'issues' => $issues, + 'channels' => $channelRows, + ]; + } + + private function releaseStatusChannelRow( + array $channel, + ?array $productionServiceChannel, + array $targetsByChannelApp, + array $deployments, + array $deploymentsByChannelApp, + array $serviceSetsByChannel, + array $bundlesByChannel + ): array { + $channelId = (int)($channel['id'] ?? 0); + $channelSlug = (string)($channel['slug'] ?? ''); + $channelName = (string)($channel['name'] ?? $channelSlug); + $channel = $this->releaseStatusChannelWithProductionServices($channel, $productionServiceChannel, $targetsByChannelApp); + $channelWithTargetEndpoints = $this->releaseStatusChannelWithTargetEndpoints($channel, $targetsByChannelApp); + $availability = $this->releaseStatusChannelAvailability($channelWithTargetEndpoints); + $services = $this->releaseStatusServicesForChannel( + $channelWithTargetEndpoints, + $availability, + $targetsByChannelApp, + $deploymentsByChannelApp, + $serviceSetsByChannel + ); + + $issues = []; + $missingValues = []; + foreach ($availability['missing'] as $missingKey) { + $missing = [ + 'key' => $missingKey, + 'label' => self::releaseStatusMissingValueLabel($missingKey), + 'service_key' => self::releaseStatusMissingServiceKey($missingKey), + 'target_tab' => self::releaseStatusMissingTargetTab($missingKey), + ]; + $missingValues[] = $missing; + $issues[] = self::releaseStatusIssue([ + 'severity' => 'critical', + 'type' => 'missing_value', + 'channel_id' => $channelId, + 'channel_slug' => $channelSlug, + 'service_key' => $missing['service_key'], + 'label' => $missing['label'], + 'message' => $channelName . ' is missing ' . $missing['label'] . '.', + 'next_action' => self::releaseStatusMissingNextAction($missingKey), + 'target_tab' => $missing['target_tab'], + 'missing_key' => $missingKey, + ]); + } + + foreach ($services as $service) { + if (self::releaseStatusSeverityRank($service['severity'] ?? 'ok') === 0 || empty($service['issue_type'])) { + continue; + } + $issues[] = self::releaseStatusIssue([ + 'severity' => (string)$service['severity'], + 'type' => (string)$service['issue_type'], + 'channel_id' => $channelId, + 'channel_slug' => $channelSlug, + 'service_key' => (string)$service['service_key'], + 'label' => (string)$service['label'], + 'message' => (string)$service['message'], + 'next_action' => (string)$service['next_action'], + 'target_tab' => (string)$service['target_tab'], + 'target_id' => $service['target_id'] ?? null, + 'deployment_id' => $service['deployment_id'] ?? null, + 'coolify_target_id' => $service['coolify_target_id'] ?? null, + 'missing_key' => $service['missing_key'] ?? null, + 'service_set_id' => $service['service_set_id'] ?? null, + ]); + } + + $issues = array_map( + fn(array $issue): array => $this->releaseStatusIssueWithActions( + $issue, + $channel, + $services, + $bundlesByChannel[$channelId] ?? [] + ), + $issues + ); + + $severity = 'ok'; + foreach ($issues as $issue) { + $severity = self::releaseStatusMaxSeverity($severity, (string)($issue['severity'] ?? 'ok')); + } + $readiness = $severity === 'critical' ? 'blocked' : ($severity === 'warning' ? 'attention' : 'ready'); + $latestDeployments = array_slice(array_values(array_filter( + $deployments, + static fn(array $deployment): bool => (int)($deployment['channel_id'] ?? 0) === $channelId + )), 0, 5); + + return [ + 'channel_id' => $channelId, + 'channel_slug' => $channelSlug, + 'channel_name' => $channelName, + 'default_channel' => (bool)($channelWithTargetEndpoints['default_channel'] ?? false), + 'enabled' => (bool)($channelWithTargetEndpoints['enabled'] ?? true), + 'service_policy' => (string)($channelWithTargetEndpoints['service_policy'] ?? 'channel'), + 'service_channel_id' => $channelWithTargetEndpoints['_service_channel_id'] ?? $channelId, + 'service_channel_slug' => $channelWithTargetEndpoints['_service_channel_slug'] ?? $channelSlug, + 'severity' => $severity, + 'readiness' => $readiness, + 'message' => self::releaseStatusChannelMessage($readiness, count($issues)), + 'availability' => $availability, + 'missing_values' => $missingValues, + 'services' => $services, + 'versions' => is_array($channelWithTargetEndpoints['versions'] ?? null) ? $channelWithTargetEndpoints['versions'] : [], + 'replay' => [ + 'enabled' => (bool)($channelWithTargetEndpoints['replay_enabled'] ?? false), + 'capture_level' => (string)($channelWithTargetEndpoints['capture_level'] ?? 'metadata'), + ], + 'latest_deployments' => $latestDeployments, + 'issues' => $issues, + ]; + } + + private function releaseStatusProductionServiceChannel(array $channels): ?array + { + $normalized = array_values(array_filter($channels, static fn(mixed $channel): bool => is_array($channel))); + + foreach ($normalized as $channel) { + if ( + ((bool)($channel['default_channel'] ?? false) || (int)($channel['default_channel'] ?? 0) === 1) + && !$this->channelUsesProductionServices($channel) + ) { + return $channel; + } + } + + foreach (self::BETA_PRODUCTION_DATA_SOURCE_CHANNELS as $slug) { + foreach ($normalized as $channel) { + if ( + self::safeSlug((string)($channel['slug'] ?? '')) === $slug + && !$this->channelUsesProductionServices($channel) + ) { + return $channel; + } + } + } + + foreach ($normalized as $channel) { + if (!$this->channelUsesProductionServices($channel)) { + return $channel; + } + } + + return null; + } + + private function releaseStatusChannelWithProductionServices( + array $channel, + ?array $productionServiceChannel, + array $targetsByChannelApp + ): array { + if (!$this->channelUsesProductionServices($channel) || $productionServiceChannel === null) { + return $channel; + } + + $source = $this->releaseStatusChannelWithTargetEndpoints($productionServiceChannel, $targetsByChannelApp); + $channel['_uses_production_services'] = true; + $channel['_service_channel_id'] = (int)($source['id'] ?? 0); + $channel['_service_channel_slug'] = (string)($source['slug'] ?? ''); + $channel['service_policy'] = self::PRODUCTION_SERVICE_POLICY; + $channel['versions'] = is_array($source['versions'] ?? null) ? $source['versions'] : []; + + foreach (['frontend_base_url', 'api_base_url'] as $field) { + if (!empty($source[$field])) { + $channel[$field] = $source[$field]; + } + } + + return $channel; + } + + private function releaseStatusChannelWithTargetEndpoints(array $channel, array $targetsByChannelApp): array + { + $channelId = (int)($channel['id'] ?? 0); + if ($channelId <= 0) { + return $channel; + } + + foreach (self::APPS as $app) { + $field = $app === 'frontend' ? 'frontend_base_url' : 'api_base_url'; + if (!empty($channel[$field])) { + continue; + } + $target = $targetsByChannelApp[$channelId . ':' . $app] ?? null; + if (!is_array($target)) { + continue; + } + $endpointUrl = is_array($target['endpoint'] ?? null) + ? $this->normalizeReleasePublicBaseUrl($target['endpoint']['url'] ?? null, $app) + : null; + $endpointUrl ??= $this->releaseTargetPublicBaseUrl($target + [ + 'channel_slug' => $channel['slug'] ?? $target['channel_slug'] ?? '', + ]); + if ($endpointUrl !== null) { + $channel[$field] = $endpointUrl; + } + } + + return $channel; + } + + private function releaseStatusServicesForChannel( + array $channel, + array $availability, + array $targetsByChannelApp, + array $deploymentsByChannelApp, + array $serviceSetsByChannel + ): array { + $channelId = (int)($channel['id'] ?? 0); + $appServiceChannelId = (int)($channel['_service_channel_id'] ?? $channelId); + $versions = is_array($channel['versions'] ?? null) ? $channel['versions'] : []; + $serviceSet = is_array($versions['service_set'] ?? null) + ? $versions['service_set'] + : ($serviceSetsByChannel[$channelId][0] ?? null); + $missingLookup = array_fill_keys($availability['missing'] ?? [], true); + + $services = []; + foreach (self::APPS as $app) { + $key = $appServiceChannelId . ':' . $app; + $services[] = $this->releaseStatusAppServiceRow( + $app, + $channel, + is_array($versions[$app] ?? null) ? $versions[$app] : null, + is_array($targetsByChannelApp[$key] ?? null) ? $targetsByChannelApp[$key] : null, + is_array($deploymentsByChannelApp[$key] ?? null) ? $deploymentsByChannelApp[$key] : null, + $missingLookup + ); + } + + foreach (self::STACK_DATA_KINDS as $kind) { + $services[] = $this->releaseStatusDataServiceRow($kind, $channel, is_array($serviceSet) ? $serviceSet : null); + } + + return $services; + } + + private function releaseStatusAppServiceRow( + string $app, + array $channel, + ?array $version, + ?array $target, + ?array $deployment, + array $missingLookup + ): array { + $serviceLabel = self::releaseStatusServiceLabel($app); + $missingKeys = $app === 'frontend' + ? ['frontend_version', 'frontend_base_url'] + : ['api_version', 'api_base_url']; + $missingKey = null; + foreach ($missingKeys as $key) { + if (isset($missingLookup[$key])) { + $missingKey = $key; + break; + } + } + + $row = [ + 'service_key' => $app, + 'label' => $serviceLabel, + 'status' => (string)($deployment['status'] ?? $version['status'] ?? 'ready'), + 'state' => 'ready', + 'severity' => 'ok', + 'message' => $serviceLabel . ' release service is ready.', + 'next_action' => '', + 'target_tab' => 'overview', + 'target_id' => $target['id'] ?? null, + 'deployment_id' => $deployment['id'] ?? null, + 'version_label' => $version['version_label'] ?? null, + 'commit_sha' => $version['commit_sha'] ?? $deployment['commit_sha'] ?? null, + 'repository' => $target['repository'] ?? $deployment['repository'] ?? $version['repository'] ?? null, + 'branch' => $target['branch'] ?? $deployment['branch'] ?? $version['branch'] ?? null, + 'health_url' => $target['health_url'] ?? null, + 'issue_type' => null, + ]; + + $deploymentStatus = strtolower((string)($deployment['status'] ?? '')); + if (in_array($deploymentStatus, ['failed', 'error'], true)) { + return array_replace($row, [ + 'state' => 'failed', + 'severity' => 'critical', + 'message' => trim((string)( + $deployment['failure_summary']['root_cause'] + ?? $deployment['error_message'] + ?? ($serviceLabel . ' deployment failed.') + )), + 'next_action' => trim((string)( + $deployment['failure_summary']['next_action'] + ?? 'Open the deployment details and fix the failing release before promotion.' + )), + 'target_tab' => 'deployments', + 'issue_type' => 'failed_deployment', + ]); + } + + if (in_array($deploymentStatus, ['queued', 'running', 'deploying', 'building', 'pending'], true)) { + return array_replace($row, [ + 'state' => 'deployment_in_progress', + 'severity' => 'warning', + 'message' => $serviceLabel . ' deployment is still in progress.', + 'next_action' => 'Wait for the deployment to finish, then refresh Release Manager.', + 'target_tab' => 'deployments', + 'issue_type' => 'deployment_in_progress', + ]); + } + + if ($missingKey !== null) { + return array_replace($row, [ + 'state' => 'missing_value', + 'status' => 'missing', + 'severity' => 'critical', + 'message' => $serviceLabel . ' is missing ' . self::releaseStatusMissingValueLabel($missingKey) . '.', + 'next_action' => self::releaseStatusMissingNextAction($missingKey), + 'target_tab' => self::releaseStatusMissingTargetTab($missingKey), + 'missing_key' => $missingKey, + ]); + } + + if (($channel['_uses_production_services'] ?? false) === true) { + return array_replace($row, [ + 'status' => self::PRODUCTION_SERVICE_POLICY, + 'service_policy' => self::PRODUCTION_SERVICE_POLICY, + 'service_channel_slug' => (string)($channel['_service_channel_slug'] ?? ''), + 'message' => $serviceLabel . ' uses the production service for this channel.', + ]); + } + + $isDefaultChannel = (bool)($channel['default_channel'] ?? false) || (string)($channel['slug'] ?? '') === 'stable'; + if (!$isDefaultChannel && is_array($target) && trim((string)($target['coolify_service_uuid'] ?? '')) === '') { + return array_replace($row, [ + 'state' => 'stale_unknown', + 'status' => 'missing_coolify_service', + 'severity' => 'warning', + 'message' => $serviceLabel . ' target is missing its Coolify service UUID.', + 'next_action' => 'Open Integrations and connect or create the Coolify service.', + 'target_tab' => 'integrations', + 'issue_type' => 'stale_unknown', + 'missing_key' => $app . '_coolify_service_uuid', + ]); + } + + return $row; + } + + private function releaseStatusDataServiceRow(string $kind, array $channel, ?array $serviceSet): array + { + $serviceLabel = self::releaseStatusServiceLabel($kind); + $isDefaultChannel = (bool)($channel['default_channel'] ?? false) || (string)($channel['slug'] ?? '') === 'stable'; + $mode = (string)($serviceSet['mode'] ?? ''); + $dataPolicy = $this->serviceSetDataPolicy($serviceSet); + $usesSharedProduction = $dataPolicy === self::PRODUCTION_DATA_POLICY; + $stack = is_array($serviceSet['stack'] ?? null) ? $serviceSet['stack'] : []; + $dataServices = is_array($serviceSet['data_services'] ?? null) ? $serviceSet['data_services'] : []; + $service = is_array($stack[$kind] ?? null) ? $stack[$kind] : (is_array($dataServices[$kind] ?? null) ? $dataServices[$kind] : null); + $row = [ + 'service_key' => $kind, + 'label' => $serviceLabel, + 'status' => $usesSharedProduction ? 'production_shared' : 'ready', + 'data_policy' => $dataPolicy, + 'data_service_mode' => $dataPolicy, + 'state' => 'ready', + 'severity' => 'ok', + 'message' => $usesSharedProduction + ? $serviceLabel . ' uses the production-shared service and is not replaced by channel sync.' + : $serviceLabel . ' release service is ready.', + 'next_action' => '', + 'target_tab' => 'data-services', + 'service_set_id' => isset($serviceSet['id']) ? (int)$serviceSet['id'] : null, + 'coolify_target_id' => $service['id'] ?? null, + 'resource_uuid' => $service['resource_uuid'] ?? null, + 'resource_name' => $service['resource_name'] ?? $service['label'] ?? null, + 'issue_type' => null, + ]; + + if ($usesSharedProduction) { + return $row; + } + + if ($service === null) { + $critical = $mode === 'isolated_stack'; + return array_replace($row, [ + 'status' => 'missing', + 'state' => 'missing_value', + 'severity' => $critical ? 'critical' : 'warning', + 'message' => $serviceLabel . ' is not assigned to this service set.', + 'next_action' => $critical + ? 'Add the missing isolated data service before deploying or promoting this bundle.' + : 'Review the service set and attach the data service if this channel needs isolated data.', + 'issue_type' => 'missing_value', + 'missing_key' => $kind . '_service', + ]); + } + + $deploymentStatus = strtolower((string)($service['deployment_status'] ?? '')); + $availabilityState = strtolower((string)($service['availability_state'] ?? '')); + $replication = is_array($service['replication'] ?? null) ? $service['replication'] : []; + $replicationLastStatus = is_array($replication['last_status'] ?? null) ? $replication['last_status'] : []; + $replicationStatus = strtolower((string)($replicationLastStatus['status'] ?? $replication['status'] ?? '')); + $blockers = array_values(array_filter( + is_array($replicationLastStatus['blockers'] ?? null) ? $replicationLastStatus['blockers'] : [] + )); + + $row['status'] = $deploymentStatus ?: ($availabilityState ?: ($replicationStatus ?: 'ready')); + + if ( + in_array($deploymentStatus, ['failed', 'reconcile_failed', 'restart_failed', 'provision_blocked'], true) + || in_array($availabilityState, ['degraded', 'failover_blocked', 'destructive_action_required'], true) + ) { + return array_replace($row, [ + 'state' => 'service_unhealthy', + 'severity' => 'critical', + 'message' => $serviceLabel . ' Coolify target is unhealthy.', + 'next_action' => 'Open Bundles or Integrations and inspect the Coolify target before promotion.', + 'issue_type' => 'service_unhealthy', + ]); + } + + if (in_array($deploymentStatus, ['created', 'deploying', 'restarting', 'waiting_for_coolify', 'provisioning'], true)) { + return array_replace($row, [ + 'state' => 'deployment_in_progress', + 'severity' => 'warning', + 'message' => $serviceLabel . ' service provisioning is still in progress.', + 'next_action' => 'Wait for Coolify provisioning to finish, then refresh Release Manager.', + 'issue_type' => 'deployment_in_progress', + ]); + } + + if ($replicationStatus !== '' && !in_array($replicationStatus, ['ok', 'ready', 'protected', 'healthy'], true)) { + return array_replace($row, [ + 'state' => 'service_unhealthy', + 'severity' => $blockers === [] ? 'warning' : 'critical', + 'message' => $blockers[0] ?? ($serviceLabel . ' replication is not healthy.'), + 'next_action' => 'Check replication status before promoting this release bundle.', + 'issue_type' => 'service_unhealthy', + ]); + } + + return $row; + } + + private function releaseStatusTargetsByChannelApp(array $targets): array + { + $indexed = []; + foreach ($targets as $target) { + if (!is_array($target)) { + continue; + } + $channelId = (int)($target['channel_id'] ?? 0); + $app = (string)($target['app'] ?? ''); + if ($channelId > 0 && in_array($app, self::APPS, true)) { + $indexed[$channelId . ':' . $app] = $target; + } + } + return $indexed; + } + + private function releaseStatusLatestDeploymentsByChannelApp(array $deployments): array + { + $indexed = []; + foreach ($deployments as $deployment) { + $channelId = (int)($deployment['channel_id'] ?? 0); + $app = (string)($deployment['app'] ?? ''); + $key = $channelId . ':' . $app; + if ($channelId > 0 && in_array($app, self::APPS, true) && !isset($indexed[$key])) { + $indexed[$key] = $deployment; + } + } + return $indexed; + } + + private function releaseStatusServiceSetsByChannel(array $serviceSets): array + { + $indexed = []; + foreach ($serviceSets as $set) { + if (!is_array($set)) { + continue; + } + $channelId = (int)($set['channel_id'] ?? 0); + if ($channelId > 0) { + $indexed[$channelId][] = $set; + } + } + return $indexed; + } + + private function releaseStatusBundlesByChannel(array $bundles): array + { + $indexed = []; + foreach ($bundles as $bundle) { + if (!is_array($bundle)) { + continue; + } + $channelId = (int)($bundle['channel_id'] ?? 0); + if ($channelId > 0) { + $indexed[$channelId][] = $bundle; + } + } + return $indexed; + } + + private function releaseStatusChannelAvailability(array $channel): array + { + if (($channel['_uses_production_services'] ?? false) !== true && is_array($channel['availability'] ?? null)) { + $availability = $channel['availability']; + $missing = self::releaseStatusReadinessMissingValues( + is_array($availability['missing'] ?? null) ? $availability['missing'] : [] + ); + $status = (string)($availability['status'] ?? ''); + if ($status === '' || ($missing === [] && in_array($status, ['unconfigured', 'missing_target'], true))) { + $status = $missing === [] ? 'ready' : 'unconfigured'; + } + return [ + 'configured' => $missing === [] + ? true + : (($availability['configured'] ?? null) === null ? false : (bool)$availability['configured']), + 'missing' => $missing, + 'status' => $status, + 'bundle_id' => $availability['bundle_id'] ?? null, + 'frontend_base_url' => $availability['frontend_base_url'] ?? $channel['frontend_base_url'] ?? null, + 'api_base_url' => $availability['api_base_url'] ?? $channel['api_base_url'] ?? null, + ]; + } + + $isDefault = (bool)($channel['default_channel'] ?? false) || (string)($channel['slug'] ?? '') === 'stable'; + if ($isDefault) { + return [ + 'configured' => true, + 'missing' => [], + 'status' => 'ready', + 'frontend_base_url' => $channel['frontend_base_url'] ?? null, + 'api_base_url' => $channel['api_base_url'] ?? null, + ]; + } + + $versions = is_array($channel['versions'] ?? null) ? $channel['versions'] : []; + $missing = []; + if (empty($versions['frontend'])) { + $missing[] = 'frontend_version'; + } elseif (empty($channel['frontend_base_url'])) { + $missing[] = 'frontend_base_url'; + } + if (empty($versions['api'])) { + $missing[] = 'api_version'; + } elseif (empty($channel['api_base_url'])) { + $missing[] = 'api_base_url'; + } + + return [ + 'configured' => $missing === [], + 'missing' => $missing, + 'status' => $missing === [] ? 'ready' : 'unconfigured', + 'bundle_id' => $versions['bundle_id'] ?? null, + 'frontend_base_url' => $channel['frontend_base_url'] ?? null, + 'api_base_url' => $channel['api_base_url'] ?? null, + ]; + } + + private static function releaseStatusReadinessMissingValues(array $missing): array + { + $normalized = []; + foreach ($missing as $key) { + $value = trim((string)$key); + if ($value === '' || $value === 'release_bundle') { + continue; + } + $normalized[] = $value; + } + + return array_values(array_unique($normalized)); + } + + private static function releaseStatusIssue(array $issue): array + { + $normalized = [ + 'severity' => (string)($issue['severity'] ?? 'warning'), + 'type' => (string)($issue['type'] ?? 'stale_unknown'), + 'channel_id' => isset($issue['channel_id']) ? (int)$issue['channel_id'] : null, + 'channel_slug' => (string)($issue['channel_slug'] ?? ''), + 'service_key' => $issue['service_key'] ?? null, + 'label' => (string)($issue['label'] ?? ''), + 'message' => (string)($issue['message'] ?? ''), + 'next_action' => (string)($issue['next_action'] ?? ''), + 'target_tab' => (string)($issue['target_tab'] ?? 'overview'), + 'target_id' => $issue['target_id'] ?? null, + 'deployment_id' => $issue['deployment_id'] ?? null, + 'coolify_target_id' => $issue['coolify_target_id'] ?? null, + 'service_set_id' => $issue['service_set_id'] ?? null, + 'missing_key' => $issue['missing_key'] ?? null, + 'impact' => (string)($issue['impact'] ?? ''), + 'resolution_state' => (string)($issue['resolution_state'] ?? 'open'), + 'actions' => is_array($issue['actions'] ?? null) ? $issue['actions'] : [], + ]; + $normalized['key'] = (string)($issue['key'] ?? self::releaseStatusIssueKey($normalized)); + if ($normalized['impact'] === '') { + $normalized['impact'] = self::releaseStatusIssueImpact($normalized); + } + return $normalized; + } + + private function releaseStatusIssueWithActions(array $issue, array $channel, array $services, array $channelBundles): array + { + $issue = self::releaseStatusIssue($issue); + $service = null; + foreach ($services as $candidate) { + if ((string)($candidate['service_key'] ?? '') === (string)($issue['service_key'] ?? '')) { + $service = $candidate; + break; + } + } + + if (($issue['target_id'] ?? null) === null && isset($service['target_id'])) { + $issue['target_id'] = $service['target_id']; + } + if (($issue['deployment_id'] ?? null) === null && isset($service['deployment_id'])) { + $issue['deployment_id'] = $service['deployment_id']; + } + if (($issue['coolify_target_id'] ?? null) === null && isset($service['coolify_target_id'])) { + $issue['coolify_target_id'] = $service['coolify_target_id']; + } + if (($issue['service_set_id'] ?? null) === null && isset($service['service_set_id'])) { + $issue['service_set_id'] = $service['service_set_id']; + } + $issue['key'] = self::releaseStatusIssueKey($issue); + $issue['impact'] = $issue['impact'] !== '' ? $issue['impact'] : self::releaseStatusIssueImpact($issue); + $issue['resolution_state'] = self::releaseStatusResolutionState($issue); + $issue['actions'] = $this->releaseStatusIssueActions($issue, $channel, $service, $channelBundles); + return $issue; + } + + private function releaseStatusIssueActions(array $issue, array $channel, ?array $service, array $channelBundles): array + { + $type = (string)($issue['type'] ?? ''); + $missingKey = (string)($issue['missing_key'] ?? ''); + $serviceKey = (string)($issue['service_key'] ?? ''); + $actions = []; + + if ($type === 'failed_deployment') { + $actions[] = self::releaseStatusAction( + 'retry_deployment', + 'Retry deployment', + 'mutation', + true, + empty($issue['deployment_id']), + empty($issue['deployment_id']) ? 'The failed deployment record is missing.' : '' + ); + if (self::releaseStatusIssueNeedsApplicationTarget($issue)) { + $actions[] = self::releaseStatusAction( + 'prepare_application_target', + 'Prepare application target', + 'mutation', + true, + empty($issue['target_id']), + empty($issue['target_id']) ? 'The deployment target is missing.' : '' + ); + } + } + + if ($type === 'missing_value' && in_array($missingKey, ['frontend_version', 'api_version'], true)) { + $actions[] = self::releaseStatusAction( + 'deploy_missing_version', + 'Deploy missing version', + 'mutation', + true, + empty($issue['target_id']), + empty($issue['target_id']) ? 'Select or create a deployment target first.' : '' + ); + } + + if ($type === 'missing_value' && $missingKey === 'release_bundle') { + $eligibleBundles = self::releaseStatusEligibleBundles($channelBundles); + $actions[] = self::releaseStatusAction( + 'set_bundle', + count($eligibleBundles) === 1 ? 'Set available bundle' : 'Choose release bundle', + 'mutation', + true, + count($eligibleBundles) !== 1, + $eligibleBundles === [] ? 'No deployed bundle is available for this channel.' : '', + ['bundle_choices' => $eligibleBundles] + ); + } + + if ($type === 'missing_value' && in_array($missingKey, ['database_service', 'redis_service', 'minio_service'], true)) { + $actions[] = self::releaseStatusAction( + 'complete_data_services', + 'Create missing data services', + 'mutation', + true, + empty($issue['service_set_id']), + empty($issue['service_set_id']) ? 'The isolated service set is missing.' : '' + ); + } + + if ($type === 'service_unhealthy' && in_array($serviceKey, self::STACK_DATA_KINDS, true)) { + foreach ([ + 'reconcile_coolify_target' => 'Reconcile target', + 'redeploy_coolify_target' => 'Redeploy target', + 'restart_coolify_target' => 'Restart target', + ] as $id => $label) { + $actions[] = self::releaseStatusAction( + $id, + $label, + 'mutation', + true, + empty($issue['coolify_target_id']), + empty($issue['coolify_target_id']) ? 'The Coolify target is missing.' : '' + ); + } + } + + if ($type === 'deployment_in_progress') { + $actions[] = self::releaseStatusAction('refresh_status', 'Refresh status', 'refresh', false, false); + } + + return $actions; + } + + private static function releaseStatusAction( + string $id, + string $label, + string $kind, + bool $requiresConfirmation, + bool $requiresInput, + string $disabledReason = '', + array $extra = [] + ): array { + return array_replace([ + 'id' => $id, + 'label' => $label, + 'kind' => $kind, + 'requires_confirmation' => $requiresConfirmation, + 'requires_input' => $requiresInput, + 'disabled_reason' => $disabledReason, + 'permission' => 'superuser_release_manager_deploy', + ], $extra); + } + + private static function releaseStatusIssueKey(array $issue): string + { + return implode(':', [ + self::safeIdentifier((string)($issue['type'] ?? 'unknown'), 32) ?: 'unknown', + self::safeIdentifier((string)($issue['channel_id'] ?? $issue['channel_slug'] ?? ''), 64), + self::safeIdentifier((string)($issue['service_key'] ?? ''), 32), + self::safeIdentifier((string)($issue['missing_key'] ?? ''), 64), + self::safeIdentifier((string)($issue['deployment_id'] ?? ''), 64), + self::safeIdentifier((string)($issue['coolify_target_id'] ?? ''), 64), + ]); + } + + private static function releaseStatusIssueImpact(array $issue): string + { + return match ((string)($issue['type'] ?? '')) { + 'failed_deployment' => 'This channel cannot be promoted until the failed deployment is replaced by a successful one.', + 'missing_value' => 'This channel is incomplete and cannot receive traffic safely.', + 'service_unhealthy' => 'This channel has an unhealthy runtime service and should not be promoted.', + 'deployment_in_progress' => 'Promotion should wait until the deployment or provisioning job finishes.', + default => 'Review this release issue before publishing or promoting the channel.', + }; + } + + private static function releaseStatusResolutionState(array $issue): string + { + if ((string)($issue['severity'] ?? '') === 'critical') { + return 'blocked'; + } + if ((string)($issue['severity'] ?? '') === 'warning') { + return 'action_available'; + } + return 'open'; + } + + private static function releaseStatusIssueNeedsApplicationTarget(array $issue): bool + { + $text = strtolower(trim((string)($issue['message'] ?? '') . ' ' . (string)($issue['next_action'] ?? ''))); + return str_contains($text, 'stripprefix') + || str_contains($text, 'path-routed') + || str_contains($text, 'service creation') + || str_contains($text, 'coolify service'); + } + + private static function releaseStatusEligibleBundles(array $bundles): array + { + $eligible = []; + foreach ($bundles as $bundle) { + $status = strtolower((string)($bundle['status'] ?? '')); + if (!in_array($status, ['deployed', 'promoted', 'active'], true)) { + continue; + } + $eligible[] = [ + 'id' => (int)($bundle['id'] ?? 0), + 'label' => (string)($bundle['version_label'] ?? ('Bundle #' . (int)($bundle['id'] ?? 0))), + 'status' => (string)($bundle['status'] ?? ''), + ]; + } + return $eligible; + } + + private static function releaseStatusSeverityRank(string $severity): int + { + return match ($severity) { + 'critical' => 2, + 'warning' => 1, + default => 0, + }; + } + + private static function releaseStatusMaxSeverity(string $a, string $b): string + { + return self::releaseStatusSeverityRank($b) > self::releaseStatusSeverityRank($a) ? $b : $a; + } + + private static function releaseStatusChannelMessage(string $readiness, int $issueCount): string + { + if ($readiness === 'ready') { + return 'All release services are ready.'; + } + if ($readiness === 'blocked') { + return $issueCount . ' blocker' . ($issueCount === 1 ? '' : 's') . ' need attention before promotion.'; + } + return $issueCount . ' warning' . ($issueCount === 1 ? '' : 's') . ' should be reviewed.'; + } + + private static function releaseStatusServiceLabel(string $service): string + { + return match ($service) { + 'frontend' => 'Frontend', + 'api' => 'API', + 'database' => 'Database', + 'redis' => 'Redis', + 'minio' => 'MinIO', + default => ucfirst(str_replace('_', ' ', $service)), + }; + } + + private static function releaseStatusMissingValueLabel(string $key): string + { + return match ($key) { + 'release_bundle' => 'release bundle', + 'frontend_version' => 'frontend version', + 'frontend_base_url' => 'frontend URL', + 'api_version' => 'API version', + 'api_base_url' => 'API URL', + 'database_service' => 'database service', + 'redis_service' => 'Redis service', + 'minio_service' => 'MinIO service', + default => str_replace('_', ' ', $key), + }; + } + + private static function releaseStatusMissingServiceKey(string $key): ?string + { + return match ($key) { + 'frontend_version', 'frontend_base_url', 'frontend_coolify_service_uuid' => 'frontend', + 'api_version', 'api_base_url', 'api_coolify_service_uuid' => 'api', + 'database_service' => 'database', + 'redis_service' => 'redis', + 'minio_service' => 'minio', + default => null, + }; + } + + private static function releaseStatusMissingTargetTab(string $key): string + { + return match ($key) { + 'release_bundle', 'database_service', 'redis_service', 'minio_service' => 'bundles', + 'frontend_version', 'api_version' => 'deployments', + 'frontend_base_url', 'api_base_url', 'frontend_coolify_service_uuid', 'api_coolify_service_uuid' => 'integrations', + default => 'overview', + }; + } + + private static function releaseStatusMissingNextAction(string $key): string + { + return match ($key) { + 'release_bundle' => 'Create or deploy a release bundle, then attach it to the channel.', + 'frontend_version', 'api_version' => 'Deploy the missing application version for this channel.', + 'frontend_base_url', 'api_base_url' => 'Set the public release URL from the deployment target or channel configuration.', + 'database_service', 'redis_service', 'minio_service' => 'Add the missing data service to the isolated service set.', + default => 'Open Release Manager details and complete the missing value.', + }; + } + + public function releaseConfig(): array + { + $this->ensureSchema(); + $storedToken = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_token', '')); + $storedWebhookSecret = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_webhook_secret', '')); + + return [ + 'github_token_configured' => $this->hasGithubApiToken(), + 'github_token_env_configured' => $this->githubEnvToken() !== '', + 'github_token_module_configured' => $storedToken !== '', + 'github_token_variable' => 'ReleaseManager.github_token', + 'github_token_env_variable' => 'RELEASE_MANAGER_GITHUB_TOKEN', + 'github_api_url' => $this->githubApiBaseUrl(), + 'github_api_url_variable' => 'ReleaseManager.github_api_url', + 'github_webhook_secret_configured' => $storedWebhookSecret !== '', + 'github_webhook_secret_variable' => 'ReleaseManager.github_webhook_secret', + ]; + } + + public function updateReleaseConfig(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $updated = []; + + if (array_key_exists('github_api_url', $input)) { + $apiUrl = rtrim(trim((string)$input['github_api_url']), '/'); + if ($apiUrl === '') { + $apiUrl = 'https://api.github.com'; + } + if (preg_match('#^https?://#i', $apiUrl) !== 1) { + throw new RuntimeException('GitHub API URL must start with http:// or https://.'); + } + $this->upsertModuleConfigValue('ReleaseManager', 'github_api_url', $apiUrl, 'string'); + $updated[] = 'github_api_url'; + } + + if (array_key_exists('github_token', $input)) { + $token = trim((string)$input['github_token']); + if ($token !== '' && $token !== '[redacted]' && !str_starts_with($token, 'twsec:v1:') && class_exists(replication_secret_box::class)) { + $token = replication_secret_box::encrypt($token); + } + if ($token !== '' && $token !== '[redacted]') { + $this->upsertModuleConfigValue('ReleaseManager', 'github_token', $token, 'string'); + $updated[] = 'github_token'; + } + } + + if ($this->toBool($input['clear_github_token'] ?? false)) { + $this->upsertModuleConfigValue('ReleaseManager', 'github_token', '', 'string'); + $updated[] = 'github_token'; + } + + if (array_key_exists('github_webhook_secret', $input)) { + $secret = trim((string)$input['github_webhook_secret']); + if ($secret !== '' && $secret !== '[redacted]') { + $this->upsertModuleConfigValue('ReleaseManager', 'github_webhook_secret', $secret, 'string'); + $updated[] = 'github_webhook_secret'; + } + } + + $this->audit(null, null, 'release_config_updated', $actorUserId, 'info', [ + 'updated' => array_values(array_unique($updated)), + ]); + + return $this->releaseConfig(); + } + + public function listGithubRepositories(array $filters = []): array + { + $this->ensureSchema(); + if (!$this->hasGithubApiToken()) { + return $this->githubTokenMissingResponse(); + } + + $query = strtolower(trim((string)($filters['query'] ?? $filters['search'] ?? ''))); + $repositories = []; + for ($page = 1; $page <= 5; $page++) { + $rows = $this->githubRequest('GET', '/user/repos', [ + 'visibility' => 'all', + 'affiliation' => 'owner,collaborator,organization_member', + 'sort' => 'updated', + 'direction' => 'desc', + 'per_page' => 100, + 'page' => $page, + ]); + if (!is_array($rows)) { + break; + } + + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $repository = $this->publicGithubRepository($row); + if ($query !== '') { + $haystack = strtolower(($repository['full_name'] ?? '') . ' ' . ($repository['description'] ?? '')); + if (!str_contains($haystack, $query)) { + continue; + } + } + $repositories[$repository['full_name']] = $repository; + } + + if (count($rows) < 100) { + break; + } + } + + return [ + 'token_configured' => true, + 'github_api_url' => $this->githubApiBaseUrl(), + 'repositories' => array_values($repositories), + ]; + } + + public function listGithubBranches(array $input): array + { + $this->ensureSchema(); + $repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? '')); + if ($repository === '') { + throw new RuntimeException('GitHub repository must use owner/repo format.'); + } + if (!$this->hasGithubApiToken()) { + return $this->githubTokenMissingResponse($repository); + } + + $branches = []; + for ($page = 1; $page <= 5; $page++) { + $rows = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository) . '/branches', [ + 'per_page' => 100, + 'page' => $page, + ]); + if (!is_array($rows)) { + break; + } + + foreach ($rows as $row) { + if (is_array($row)) { + $branch = $this->publicGithubBranch($row); + $branches[$branch['name']] = $branch; + } + } + + if (count($rows) < 100) { + break; + } + } + + return [ + 'token_configured' => true, + 'repository' => $repository, + 'branches' => array_values($branches), + ]; + } + + public function listGithubCommits(array $input): array + { + $this->ensureSchema(); + $repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? '')); + if ($repository === '') { + throw new RuntimeException('GitHub repository must use owner/repo format.'); + } + if (!$this->hasGithubApiToken()) { + return $this->githubTokenMissingResponse($repository, (string)($input['branch'] ?? '')); + } + + $branch = trim((string)($input['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH; + $commit = trim((string)($input['commit_sha'] ?? $input['commit'] ?? '')); + $query = strtolower(trim((string)($input['query'] ?? $input['search'] ?? ''))); + + if ($commit !== '' && !in_array(strtolower($commit), ['latest', 'head'], true)) { + $row = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository) . '/commits/' . rawurlencode($commit)); + $publicCommit = is_array($row) ? $this->publicGithubCommit($row) : []; + return [ + 'token_configured' => true, + 'repository' => $repository, + 'branch' => $branch, + 'commits' => $publicCommit !== [] ? [$publicCommit] : [], + 'latest' => $publicCommit !== [] ? $publicCommit : null, + ]; + } + + $rows = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository) . '/commits', [ + 'sha' => $branch, + 'per_page' => 25, + ]); + $commits = []; + foreach (is_array($rows) ? $rows : [] as $row) { + if (!is_array($row)) { + continue; + } + $publicCommit = $this->publicGithubCommit($row); + if ($query !== '') { + $haystack = strtolower(($publicCommit['sha'] ?? '') . ' ' . ($publicCommit['message'] ?? '') . ' ' . ($publicCommit['author_name'] ?? '')); + if (!str_contains($haystack, $query)) { + continue; + } + } + $commits[] = $publicCommit; + } + + return [ + 'token_configured' => true, + 'repository' => $repository, + 'branch' => $branch, + 'commits' => $commits, + 'latest' => $commits[0] ?? null, + ]; + } + + public function testGithubRepositoryAccess(array $input): array + { + $this->ensureSchema(); + return $this->githubRepositoryAccess($input); + } + + public function listChannels(): array + { + if (!release_manager_schema_bootstrap::tablesExist()) { + return []; + } + + $channels = $this->selectRows( + "SELECT * FROM release_channels WHERE deleted_at IS NULL ORDER BY default_channel DESC, slug" + ); + + return array_map(function (array $channel): array { + $public = $this->publicChannel($channel); + $serviceChannel = $this->runtimeServiceChannelFor($channel); + $public['service_channel'] = $this->publicChannel($serviceChannel); + $public['service_policy'] = $this->channelUsesProductionServices($channel) + ? self::PRODUCTION_SERVICE_POLICY + : 'channel'; + $public['versions'] = $this->currentVersionsForChannel((int)$serviceChannel['id']); + $public['current_deployments'] = $this->channelCurrentDeployments((int)$serviceChannel['id']); + $public['branch_status'] = $this->channelBranchStatus($channel); + $public['data_services'] = $this->channelDataServicesSummary($channel); + $public['replication_policy'] = $public['data_services']['policy'] ?? $this->replicationPolicyForMode('production_shared'); + return $public; + }, $channels); + } + + public function createChannel(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $normalized = $this->normalizeChannelInput($input, true); + + $this->execute( + "INSERT INTO release_channels ( + slug, name, description, enabled, default_channel, rollout_percent, + frontend_base_url, api_base_url, replay_enabled, capture_level, retention_days, metadata_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + 'sssiidssisis', + [ + $normalized['slug'], + $normalized['name'], + $normalized['description'], + $normalized['enabled'], + $normalized['default_channel'], + $normalized['rollout_percent'], + $normalized['frontend_base_url'], + $normalized['api_base_url'], + $normalized['replay_enabled'], + $normalized['capture_level'], + $normalized['retention_days'], + self::jsonEncode($normalized['metadata']), + ] + ); + + $id = $this->insertId(); + if ($normalized['default_channel'] === 1) { + $this->clearOtherDefaultChannels($id); + } + $this->audit($id, null, 'channel_created', $actorUserId, 'info', $normalized); + + return $this->publicChannel($this->getChannel($id)); + } + + public function updateChannel(int $id, array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $channel = $this->getChannel($id); + $normalized = $this->normalizeChannelInput(array_replace($channel, $input), false); + + $this->execute( + "UPDATE release_channels + SET slug = ?, name = ?, description = ?, enabled = ?, default_channel = ?, rollout_percent = ?, + frontend_base_url = ?, api_base_url = ?, replay_enabled = ?, capture_level = ?, + retention_days = ?, metadata_json = ? + WHERE id = ?", + 'sssiidssisisi', + [ + $normalized['slug'], + $normalized['name'], + $normalized['description'], + $normalized['enabled'], + $normalized['default_channel'], + $normalized['rollout_percent'], + $normalized['frontend_base_url'], + $normalized['api_base_url'], + $normalized['replay_enabled'], + $normalized['capture_level'], + $normalized['retention_days'], + self::jsonEncode($normalized['metadata']), + $id, + ] + ); + + if ($normalized['default_channel'] === 1) { + $this->clearOtherDefaultChannels($id); + } + $this->audit($id, null, 'channel_updated', $actorUserId, 'info', $normalized); + + return $this->publicChannel($this->getChannel($id)); + } + + public function listAssignments(): array + { + if (!release_manager_schema_bootstrap::tablesExist()) { + return []; + } + + return array_map( + fn(array $row): array => $this->publicAssignment($row), + $this->selectRows( + "SELECT a.*, c.slug AS channel_slug, c.name AS channel_name + FROM release_assignments a + INNER JOIN release_channels c ON c.id = a.channel_id + WHERE a.deleted_at IS NULL AND (a.expires_at IS NULL OR a.expires_at > NOW()) + ORDER BY a.created_at DESC + LIMIT 250" + ) + ); + } + + public function searchAssignmentSubjects(array $input): array + { + $query = self::normalizeAssignmentSubjectSearch($input['search'] ?? $input['query'] ?? ''); + if ($query === '') { + return []; + } + + $limit = self::normalizeAssignmentSubjectLimit($input['limit'] ?? 5); + $subjects = array_merge( + $this->searchAssignmentUsers($query, $limit), + $this->searchAssignmentSubusers($query, $limit), + $this->searchAssignmentCustomers($query, $limit) + ); + + $seen = []; + $normalized = []; + foreach ($subjects as $subject) { + $item = self::publicAssignmentSubjectSuggestion($subject); + if ($item === null) { + continue; + } + $key = $item['subject_type'] . ':' . $item['subject_id']; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $normalized[] = $item; + } + + return $normalized; + } + + public function createAssignment(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $subjectType = strtolower(trim((string)($input['subject_type'] ?? ''))); + if (!in_array($subjectType, self::SUBJECT_TYPES, true)) { + throw new RuntimeException('Invalid release assignment subject type.'); + } + + $subjectId = trim((string)($input['subject_id'] ?? '')); + if ($subjectId === '') { + throw new RuntimeException('Release assignment subject_id is required.'); + } + + $channel = $this->channelFromInput($input); + $reason = trim((string)($input['reason'] ?? '')); + $expiresAt = $this->normalizeDateTime($input['expires_at'] ?? null); + + $this->execute( + "INSERT INTO release_assignments (subject_type, subject_id, channel_id, reason, expires_at, actor_user_id) + VALUES (?, ?, ?, ?, ?, ?)", + 'ssissi', + [$subjectType, $subjectId, (int)$channel['id'], $reason !== '' ? $reason : null, $expiresAt, $actorUserId] + ); + + $id = $this->insertId(); + $this->clearAssignmentCache($subjectType, $subjectId); + $this->audit((int)$channel['id'], null, 'assignment_created', $actorUserId, 'info', [ + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'channel_slug' => $channel['slug'], + ]); + + $row = $this->selectOne( + "SELECT a.*, c.slug AS channel_slug, c.name AS channel_name + FROM release_assignments a INNER JOIN release_channels c ON c.id = a.channel_id + WHERE a.id = ?", + 'i', + [$id] + ); + return $this->publicAssignment($row ?? []); + } + + public function deleteAssignment(int $id, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $assignment = $this->selectOne('SELECT * FROM release_assignments WHERE id = ? AND deleted_at IS NULL', 'i', [$id]); + if ($assignment === null) { + throw new RuntimeException('Release assignment not found.'); + } + + $this->execute('UPDATE release_assignments SET deleted_at = NOW() WHERE id = ?', 'i', [$id]); + $this->clearAssignmentCache((string)$assignment['subject_type'], (string)$assignment['subject_id']); + $this->audit((int)$assignment['channel_id'], null, 'assignment_deleted', $actorUserId, 'info', [ + 'assignment_id' => $id, + ]); + + return ['deleted' => true, 'id' => $id]; + } + + public function listDeploymentTargets(): array + { + if (!release_manager_schema_bootstrap::tablesExist()) { + return []; + } + + return array_map( + fn(array $row): array => $this->publicDeploymentTarget($row), + $this->selectRows( + "SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, i.label AS coolify_instance_label + FROM release_deployment_targets t + INNER JOIN release_channels c ON c.id = t.channel_id + LEFT JOIN coolify_instances i ON i.id = t.coolify_instance_id + WHERE t.deleted_at IS NULL + ORDER BY c.slug, FIELD(t.app, 'frontend', 'api'), t.repository" + ) + ); + } + + public function upsertDeploymentTarget(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $id = (int)($input['id'] ?? 0); + $channel = $this->channelFromInput($input); + $app = $this->normalizeApp((string)($input['app'] ?? '')); + $repository = trim((string)($input['repository'] ?? '')); + $branch = trim((string)($input['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH; + if ($repository === '') { + throw new RuntimeException('Repository is required for release deployment targets.'); + } + $normalizedRepository = self::normalizeGithubRepositoryName($repository); + if ($normalizedRepository !== '') { + $repository = $normalizedRepository; + } + + $githubAccess = $this->githubRepositoryAccess([ + 'repository' => $repository, + 'branch' => $branch, + 'commit_mode' => 'latest', + ]); + if (($githubAccess['token_configured'] ?? false) && !($githubAccess['ok'] ?? false)) { + throw new RuntimeException('GitHub repository access test failed: ' . (string)($githubAccess['message'] ?? 'Repository is not accessible.')); + } + + $deployContext = is_array($input['deploy_context'] ?? null) ? $input['deploy_context'] : []; + foreach (['coolify_auto_create', 'coolify_enable_ssl', 'coolify_deploy_now'] as $key) { + if (array_key_exists($key, $input)) { + $deployContext[$key] = $this->toBool($input[$key]); + } + } + foreach (['coolify_domain', 'coolify_public_url', 'coolify_url_name', 'manual_endpoint_host', 'coolify_ports_exposes'] as $key) { + if (array_key_exists($key, $input)) { + $deployContext[$key] = trim((string)$input[$key]); + } + } + if (array_key_exists('endpoint_mode', $input)) { + $mode = strtolower(trim((string)$input['endpoint_mode'])); + $deployContext['endpoint_mode'] = $mode === 'manual' ? 'manual' : 'auto'; + } elseif (!isset($deployContext['endpoint_mode'])) { + $deployContext['endpoint_mode'] = 'auto'; + } + if (array_key_exists('manual_endpoint_port', $input)) { + $port = trim((string)$input['manual_endpoint_port']); + $deployContext['manual_endpoint_port'] = $port; + } + $endpointMode = strtolower(trim((string)($deployContext['endpoint_mode'] ?? 'auto'))) === 'manual' ? 'manual' : 'auto'; + $deployContext['endpoint_mode'] = $endpointMode; + if ($endpointMode === 'manual') { + $manualHost = self::normalizeEndpointHost($deployContext['manual_endpoint_host'] ?? ''); + if ($manualHost === '') { + throw new RuntimeException('Manual endpoint mode requires a public host.'); + } + $deployContext['manual_endpoint_host'] = $manualHost; + } + if (array_key_exists('manual_endpoint_port', $deployContext)) { + $manualPort = trim((string)$deployContext['manual_endpoint_port']); + if ($manualPort !== '' && (filter_var($manualPort, FILTER_VALIDATE_INT) === false || (int)$manualPort < 1 || (int)$manualPort > 65535)) { + throw new RuntimeException('Manual endpoint port must be between 1 and 65535.'); + } + $deployContext['manual_endpoint_port'] = $manualPort; + } + foreach ([ + 'coolify_project_uuid', + 'project_uuid', + 'coolify_environment_uuid', + 'environment_uuid', + 'coolify_environment_name', + 'environment_name', + 'coolify_github_app_uuid', + 'github_app_uuid', + 'coolify_git_app_uuid', + 'git_app_uuid', + 'coolify_build_pack', + 'build_pack', + ] as $key) { + if (array_key_exists($key, $input)) { + $deployContext[$key] = trim((string)$input[$key]); + } + } + + $payload = [ + 'channel_id' => (int)$channel['id'], + 'app' => $app, + 'coolify_instance_id' => $this->nullablePositiveInt($input['coolify_instance_id'] ?? null), + 'coolify_service_uuid' => trim((string)($input['coolify_service_uuid'] ?? '')) ?: null, + 'repository' => $repository, + 'branch' => $branch, + 'auto_deploy' => $this->toBool($input['auto_deploy'] ?? true) ? 1 : 0, + 'health_url' => trim((string)($input['health_url'] ?? '')) ?: null, + 'deploy_context' => $deployContext, + ]; + + if ($id > 0) { + $this->execute( + "UPDATE release_deployment_targets + SET channel_id = ?, app = ?, coolify_instance_id = ?, coolify_service_uuid = ?, + repository = ?, branch = ?, auto_deploy = ?, health_url = ?, deploy_context_json = ? + WHERE id = ? AND deleted_at IS NULL", + 'isisssissi', + [ + $payload['channel_id'], + $payload['app'], + $payload['coolify_instance_id'], + $payload['coolify_service_uuid'], + $payload['repository'], + $payload['branch'], + $payload['auto_deploy'], + $payload['health_url'], + self::jsonEncode($payload['deploy_context']), + $id, + ] + ); + $targetId = $id; + $action = 'deployment_target_updated'; + } else { + $this->execute( + "INSERT INTO release_deployment_targets ( + channel_id, app, coolify_instance_id, coolify_service_uuid, + repository, branch, auto_deploy, health_url, deploy_context_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + 'isisssiss', + [ + $payload['channel_id'], + $payload['app'], + $payload['coolify_instance_id'], + $payload['coolify_service_uuid'], + $payload['repository'], + $payload['branch'], + $payload['auto_deploy'], + $payload['health_url'], + self::jsonEncode($payload['deploy_context']), + ] + ); + $targetId = $this->insertId(); + $action = 'deployment_target_created'; + } + + $this->audit((int)$channel['id'], null, $action, $actorUserId, 'info', $payload + [ + 'github_access' => $githubAccess, + ]); + return $this->publicDeploymentTarget($this->getDeploymentTarget($targetId)); + } + + public function deleteDeploymentTarget(int $id, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $target = $this->getDeploymentTarget($id); + $this->execute('UPDATE release_deployment_targets SET deleted_at = NOW() WHERE id = ?', 'i', [$id]); + $this->audit((int)$target['channel_id'], null, 'deployment_target_deleted', $actorUserId, 'warning', [ + 'target_id' => $id, + ]); + return ['deleted' => true, 'id' => $id]; + } + + public function listServiceSets(): array + { + if (!release_manager_schema_bootstrap::tablesExist()) { + return []; + } + + return array_map( + fn(array $row): array => $this->publicServiceSet($row), + $this->selectRows( + "SELECT s.*, c.slug AS channel_slug, c.name AS channel_name + FROM release_service_sets s + LEFT JOIN release_channels c ON c.id = s.channel_id + WHERE s.deleted_at IS NULL + ORDER BY s.updated_at DESC, s.created_at DESC, s.id DESC" + ) + ); + } + + public function createServiceSet(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + + $mode = $this->normalizeServiceSetMode((string)($input['mode'] ?? $input['dataset_mode'] ?? 'attach_existing')); + $sourceId = $this->nullablePositiveInt($input['source_service_set_id'] ?? $input['source_id'] ?? null); + if ($mode === 'isolated_stack') { + $sourceId = null; + } + $source = $sourceId !== null ? $this->getServiceSet($sourceId) : null; + $dataSourceId = $mode === 'isolated_stack' + ? null + : $this->nullablePositiveInt($input['data_source_service_set_id'] ?? $input['data_source_id'] ?? null); + $dataSource = $dataSourceId !== null ? $this->getServiceSet($dataSourceId) : null; + $channel = $this->channelFromInputOrDefault($input, $source); + $isBetaChannel = $this->isBetaChannel($channel); + if ($isBetaChannel && $mode !== 'attach_existing') { + throw new RuntimeException('Beta release service sets must use production-shared data services.'); + } + if ($isBetaChannel && $this->serviceSetInputHasExplicitDataTargets($input)) { + throw new RuntimeException('Beta data-only service sets must copy data targets from Stable/Master or leave them production_shared.'); + } + + $frontendTargetId = $this->serviceSetTargetIdFromInput($input, 'frontend', $source); + $apiTargetId = $this->serviceSetTargetIdFromInput($input, 'api', $source); + $dataTargets = []; + $dataTargetSource = $isBetaChannel ? $dataSource : ($dataSource ?? $source); + foreach (self::STACK_DATA_KINDS as $kind) { + $dataTargets[$kind] = $this->serviceSetDataTargetIdFromInput($input, $kind, $dataTargetSource); + } + if ($isBetaChannel) { + $this->assertBetaDataSourceChannel($dataSource); + } + + $name = trim((string)($input['name'] ?? '')); + if ($name === '') { + $name = $source !== null + ? sprintf('%s %s', (string)($source['name'] ?? 'Release service set'), str_replace('_', ' ', $mode)) + : sprintf('%s service set', ucfirst(str_replace('_', ' ', $mode))); + } + + if ($mode === 'isolated_stack') { + $this->assertIsolatedStackTarget($frontendTargetId, 'frontend'); + $this->assertIsolatedStackTarget($apiTargetId, 'api'); + $createDataTargets = $this->toBool( + $input['create_data_targets'] + ?? $input['create_isolated_data_targets'] + ?? $input['create_empty_data_targets'] + ?? true + ); + foreach (self::STACK_DATA_KINDS as $kind) { + if ($dataTargets[$kind] !== null) { + $this->assertIsolatedStackDataTarget($dataTargets[$kind], $kind); + continue; + } + if ($createDataTargets) { + $dataTargets[$kind] = $this->createIsolatedStackDataTarget( + $kind, + $input, + $channel, + $name, + $frontendTargetId, + $apiTargetId, + $actorUserId + ); + } + } + } + + if (!$isBetaChannel && !in_array($mode, ['fresh_empty', 'isolated_stack'], true) && $source === null && $frontendTargetId === null && $apiTargetId === null) { + throw new RuntimeException('Select an existing release deployment or target before creating a reusable service set.'); + } + + $metadata = is_array($input['metadata'] ?? null) ? $input['metadata'] : self::jsonDecode($input['metadata_json'] ?? null); + $metadata['dataset_mode'] = $mode; + $metadata['source_service_set_id'] = $sourceId; + $metadata['data_source_service_set_id'] = $dataSourceId; + if ($dataSource !== null) { + $metadata['data_source_channel_slug'] = (string)($dataSource['channel_slug'] ?? ''); + } + if ($mode === 'attach_existing') { + $metadata['data_policy'] = self::PRODUCTION_DATA_POLICY; + $metadata['data_service_mode'] = self::PRODUCTION_DATA_POLICY; + } + $metadata['replica_integration'] = $this->replicaProvisioningPlan($mode, $dataTargetSource, $dataTargets); + if ($mode === 'fresh_empty') { + $metadata['isolated_empty_services'] = true; + $metadata['production_replication_attached'] = false; + } + if ($mode === 'isolated_stack') { + $metadata['isolated_stack'] = true; + $metadata['isolated_empty_services'] = true; + $metadata['production_replication_attached'] = false; + $metadata['production_code_targets_attached'] = false; + } + + $slug = $this->uniqueServiceSetSlug(self::safeSlug((string)($input['slug'] ?? $name))); + $status = $isBetaChannel ? 'ready' : $this->serviceSetStatus($mode, $frontendTargetId, $apiTargetId, $dataTargets); + $stackComplete = $isBetaChannel || ($frontendTargetId !== null + && $apiTargetId !== null + && (!in_array(null, $dataTargets, true) || $mode === 'attach_existing')); + $health = [ + 'status' => $status, + 'stack_complete' => $stackComplete, + 'data_policy' => $this->serviceSetDataPolicy([ + 'mode' => $mode, + 'metadata_json' => self::jsonEncode($metadata), + ]), + 'checked_at' => date('c'), + ]; + + $this->execute( + "INSERT INTO release_service_sets ( + channel_id, name, slug, mode, source_service_set_id, + frontend_target_id, api_target_id, + database_coolify_target_id, redis_coolify_target_id, minio_coolify_target_id, + status, health_json, metadata_json, actor_user_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + 'isssiiiiiisssi', + [ + (int)$channel['id'], + substr($name, 0, 128), + $slug, + $mode, + $sourceId, + $frontendTargetId, + $apiTargetId, + $dataTargets['database'], + $dataTargets['redis'], + $dataTargets['minio'], + $status, + self::jsonEncode($health), + self::jsonEncode($metadata), + $actorUserId, + ] + ); + $id = $this->insertId(); + + $this->audit((int)$channel['id'], null, 'service_set_created', $actorUserId, 'info', [ + 'service_set_id' => $id, + 'mode' => $mode, + 'source_service_set_id' => $sourceId, + 'data_source_service_set_id' => $dataSourceId, + 'data_policy' => $this->serviceSetDataPolicy($this->getServiceSet($id)), + 'data_targets' => $dataTargets, + ]); + + return $this->publicServiceSet($this->getServiceSet($id)); + } + + public function deleteServiceSet(int $id, array $input = [], ?int $actorUserId = null): array + { + $this->ensureSchema(); + $serviceSet = $this->getServiceSet($id); + if ((string)($serviceSet['mode'] ?? '') !== 'isolated_stack') { + throw new RuntimeException('Only isolated stack service sets can be removed from Release Manager.'); + } + if ($this->serviceSetIsActive($id)) { + throw new RuntimeException('The active release service set cannot be removed.'); + } + + $frontendTargetId = $this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null); + $apiTargetId = $this->nullablePositiveInt($serviceSet['api_target_id'] ?? null); + $dataTargetIds = []; + foreach (self::STACK_DATA_KINDS as $kind) { + $dataTargetIds[$kind] = $this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null); + } + + $this->execute( + "UPDATE release_bundles + SET status = 'removed', deleted_at = NOW() + WHERE service_set_id = ? AND deleted_at IS NULL", + 'i', + [$id] + ); + $this->execute( + "UPDATE release_deployments + SET status = 'removed' + WHERE service_set_id = ?", + 'i', + [$id] + ); + $this->execute( + "UPDATE release_service_sets + SET status = 'removed', deleted_at = NOW(), actor_user_id = ? + WHERE id = ?", + 'ii', + [$actorUserId, $id] + ); + + foreach ([$frontendTargetId, $apiTargetId] as $targetId) { + if ($this->isolatedDeploymentTargetCanBeForgotten($targetId, $id)) { + $this->execute('UPDATE release_deployment_targets SET deleted_at = NOW() WHERE id = ?', 'i', [$targetId]); + } + } + foreach ($dataTargetIds as $targetId) { + if ($this->isolatedCoolifyTargetCanBeForgotten($targetId, $id)) { + $this->execute('UPDATE coolify_targets SET deleted_at = NOW() WHERE id = ?', 'i', [$targetId]); + } + } + + $this->audit((int)$serviceSet['channel_id'], null, 'service_set_removed', $actorUserId, 'warning', [ + 'service_set_id' => $id, + 'mode' => 'isolated_stack', + 'provider_resources_deleted' => false, + 'frontend_target_id' => $frontendTargetId, + 'api_target_id' => $apiTargetId, + 'data_target_ids' => $dataTargetIds, + ]); + + return [ + 'id' => $id, + 'removed' => true, + 'provider_resources_deleted' => false, + ]; + } + + public function completeIsolatedStackDataServices(int $serviceSetId, array $input = [], ?int $actorUserId = null): array + { + $this->ensureSchema(); + $serviceSet = $this->getServiceSet($serviceSetId); + if ((string)($serviceSet['mode'] ?? '') !== 'isolated_stack') { + throw new RuntimeException('Only isolated stack service sets can create isolated data services.'); + } + + $frontendTargetId = $this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null); + $apiTargetId = $this->nullablePositiveInt($serviceSet['api_target_id'] ?? null); + $this->assertIsolatedStackTarget($frontendTargetId, 'frontend', true); + $this->assertIsolatedStackTarget($apiTargetId, 'api', true); + + $channel = $this->getChannel((int)$serviceSet['channel_id']); + $name = trim((string)($input['name'] ?? $serviceSet['name'] ?? 'Isolated stack')); + $dataTargets = []; + foreach (self::STACK_DATA_KINDS as $kind) { + $dataTargets[$kind] = $this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null); + if ($dataTargets[$kind] !== null) { + $this->assertIsolatedStackDataTarget($dataTargets[$kind], $kind); + continue; + } + + $dataTargets[$kind] = $this->createIsolatedStackDataTarget( + $kind, + $input, + $channel, + $name, + $frontendTargetId, + $apiTargetId, + $actorUserId + ); + } + + $metadata = self::jsonDecode($serviceSet['metadata_json'] ?? null); + $metadata['dataset_mode'] = 'isolated_stack'; + $metadata['isolated_stack'] = true; + $metadata['isolated_empty_services'] = true; + $metadata['production_replication_attached'] = false; + $metadata['production_code_targets_attached'] = false; + $metadata['replica_integration'] = $this->replicaProvisioningPlan('isolated_stack', null, $dataTargets); + + $status = $this->serviceSetStatus('isolated_stack', $frontendTargetId, $apiTargetId, $dataTargets); + $health = [ + 'status' => $status, + 'stack_complete' => $frontendTargetId !== null && $apiTargetId !== null && !in_array(null, $dataTargets, true), + 'checked_at' => date('c'), + ]; + + $this->execute( + "UPDATE release_service_sets + SET database_coolify_target_id = ?, redis_coolify_target_id = ?, minio_coolify_target_id = ?, + status = ?, health_json = ?, metadata_json = ?, actor_user_id = ? + WHERE id = ?", + 'iiisssii', + [ + $dataTargets['database'], + $dataTargets['redis'], + $dataTargets['minio'], + $status, + self::jsonEncode($health), + self::jsonEncode($metadata), + $actorUserId, + $serviceSetId, + ] + ); + + $this->audit((int)$channel['id'], null, 'isolated_stack_data_services_created', $actorUserId, 'info', [ + 'service_set_id' => $serviceSetId, + 'data_targets' => $dataTargets, + ]); + + return $this->publicServiceSet($this->getServiceSet($serviceSetId)); + } + + public function listBundles(int $limit = 50): array + { + if (!release_manager_schema_bootstrap::tablesExist()) { + return []; + } + + $limit = max(1, min(250, $limit)); + return array_map( + fn(array $row): array => $this->publicBundle($row), + $this->selectRows( + "SELECT b.*, c.slug AS channel_slug, c.name AS channel_name, s.name AS service_set_name, s.slug AS service_set_slug + FROM release_bundles b + INNER JOIN release_channels c ON c.id = b.channel_id + INNER JOIN release_service_sets s ON s.id = b.service_set_id + WHERE b.deleted_at IS NULL + ORDER BY b.created_at DESC, b.id DESC + LIMIT $limit" + ) + ); + } + + public function createBundle(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + + $serviceSetId = $this->nullablePositiveInt($input['service_set_id'] ?? null); + if ($serviceSetId === null) { + throw new RuntimeException('A release service set is required before creating a bundle.'); + } + $serviceSet = $this->getServiceSet($serviceSetId); + $channel = $this->channelFromInputOrDefault($input, $serviceSet); + if ($this->channelUsesProductionServices($channel)) { + throw new RuntimeException('Beta release channel uses production services and does not create separate release bundles.'); + } + $this->assertBetaProductionDataPolicy($channel, $serviceSet); + + $versionLabel = trim((string)($input['version_label'] ?? '')); + if ($versionLabel === '') { + $versionLabel = sprintf('%s-bundle-%s', (string)($channel['slug'] ?? 'release'), date('Ymd-His')); + } + + $frontendTarget = $this->nullableDeploymentTarget((int)($serviceSet['frontend_target_id'] ?? 0) ?: null); + $apiTarget = $this->nullableDeploymentTarget((int)($serviceSet['api_target_id'] ?? 0) ?: null); + $frontend = $this->bundleAppInput($input, 'frontend', $frontendTarget, $versionLabel); + $api = $this->bundleAppInput($input, 'api', $apiTarget, $versionLabel); + + $frontendVersionId = $this->createBundleVersion($frontend, 'frontend'); + $apiVersionId = $this->createBundleVersion($api, 'api'); + + $metadata = is_array($input['metadata'] ?? null) ? $input['metadata'] : self::jsonDecode($input['metadata_json'] ?? null); + $metadata['service_set_id'] = $serviceSetId; + $metadata['stack_services'] = array_merge(['frontend', 'api'], self::STACK_DATA_KINDS); + $metadata['promotion_policy'] = 'attach_code_and_service_set_only'; + + $this->execute( + "INSERT INTO release_bundles ( + channel_id, service_set_id, version_label, + frontend_version_id, api_version_id, + frontend_repository, frontend_branch, frontend_commit_sha, + api_repository, api_branch, api_commit_sha, + metadata_json, actor_user_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + 'iisiisssssssi', + [ + (int)$channel['id'], + $serviceSetId, + $versionLabel, + $frontendVersionId, + $apiVersionId, + $frontend['repository'], + $frontend['branch'], + $frontend['commit_sha'], + $api['repository'], + $api['branch'], + $api['commit_sha'], + self::jsonEncode($metadata), + $actorUserId, + ] + ); + $id = $this->insertId(); + + $this->audit((int)$channel['id'], null, 'bundle_created', $actorUserId, 'info', [ + 'bundle_id' => $id, + 'service_set_id' => $serviceSetId, + 'frontend_repository' => $frontend['repository'], + 'api_repository' => $api['repository'], + ]); + + return $this->publicBundle($this->getBundle($id)); + } + + public function deployBundle(int $bundleId, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $bundle = $this->getBundle($bundleId); + $serviceSet = $this->getServiceSet((int)$bundle['service_set_id']); + $channel = $this->getChannel((int)$bundle['channel_id']); + if ($this->channelUsesProductionServices($channel)) { + throw new RuntimeException('Beta release channel uses production services and does not deploy separate release bundles.'); + } + $this->assertBetaProductionDataPolicy($channel, $serviceSet); + $results = []; + $deploymentIds = ['frontend' => null, 'api' => null]; + + foreach (['frontend', 'api'] as $app) { + $versionId = $this->nullablePositiveInt($bundle[$app . '_version_id'] ?? null); + if ($versionId === null) { + $results[$app] = ['status' => 'skipped', 'message' => 'No release version is attached to this app.']; + continue; + } + + $deployment = $this->startDeployment([ + 'channel_id' => (int)$bundle['channel_id'], + 'target_id' => $this->nullablePositiveInt($serviceSet[$app . '_target_id'] ?? null), + 'version_id' => $versionId, + 'service_set_id' => (int)$bundle['service_set_id'], + 'bundle_id' => $bundleId, + 'deployment_kind' => 'bundle_member', + 'app' => $app, + 'repository' => $bundle[$app . '_repository'] ?? '', + 'branch' => $bundle[$app . '_branch'] ?? self::DEFAULT_BRANCH, + 'commit_mode' => trim((string)($bundle[$app . '_commit_sha'] ?? '')) !== '' ? 'specific' : 'latest', + 'commit_sha' => $bundle[$app . '_commit_sha'] ?? '', + 'version_label' => $bundle['version_label'] ?? null, + ], $actorUserId); + $deploymentIds[$app] = (int)($deployment['id'] ?? 0) ?: null; + $results[$app] = $deployment; + } + + $statuses = array_map(static fn(array $result): string => strtolower((string)($result['status'] ?? 'unknown')), $results); + $status = in_array('failed', $statuses, true) + ? 'failed' + : (count(array_intersect($statuses, ['queued', 'deploying', 'unknown', 'skipped'])) > 0 ? 'deploying' : 'deployed'); + + $this->execute( + "UPDATE release_bundles + SET status = ?, frontend_deployment_id = ?, api_deployment_id = ?, + deployment_result_json = ?, deployed_at = CASE WHEN ? IN ('deployed', 'deploying') THEN NOW() ELSE deployed_at END + WHERE id = ?", + 'siissi', + [ + $status, + $deploymentIds['frontend'], + $deploymentIds['api'], + self::jsonEncode(self::redactPayload($results)), + $status, + $bundleId, + ] + ); + + $this->audit((int)$bundle['channel_id'], null, 'bundle_deployed', $actorUserId, $status === 'failed' ? 'error' : 'info', [ + 'bundle_id' => $bundleId, + 'service_set_id' => (int)$bundle['service_set_id'], + 'status' => $status, + ]); + + return $this->publicBundle($this->getBundle($bundleId)); + } + + public function promoteBundle(int $bundleId, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $bundle = $this->getBundle($bundleId); + $status = strtolower((string)($bundle['status'] ?? '')); + if (!in_array($status, ['deployed', 'active', 'promoted'], true)) { + throw new RuntimeException('Only deployed release bundles can be promoted.'); + } + + $channelId = (int)$bundle['channel_id']; + $channel = $this->getChannel($channelId); + $serviceSetId = (int)$bundle['service_set_id']; + $serviceSet = $this->getServiceSet($serviceSetId); + if ($this->channelUsesProductionServices($channel)) { + throw new RuntimeException('Beta release channel uses production services and does not promote separate release bundles.'); + } + $this->assertBetaProductionDataPolicy($channel, $serviceSet); + $this->assertReleaseGatePassedForPromotion( + $channelId, + (string)($bundle['frontend_commit_sha'] ?? ''), + null, + 'frontend' + ); + if (trim((string)($bundle['api_commit_sha'] ?? '')) !== '') { + $this->assertReleaseGatePassedForPromotion( + $channelId, + (string)$bundle['api_commit_sha'], + null, + 'api' + ); + } + $frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null); + $apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null); + $deploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null) + ?? $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null); + + $this->execute('UPDATE release_channel_versions SET active = 0 WHERE channel_id = ?', 'i', [$channelId]); + $this->execute( + "UPDATE release_bundles + SET status = 'superseded' + WHERE channel_id = ? AND id <> ? AND status = 'promoted' AND deleted_at IS NULL", + 'ii', + [$channelId, $bundleId] + ); + $this->execute( + "UPDATE release_deployments + SET status = 'superseded', active_channel_app_key = NULL + WHERE channel_id = ? AND bundle_id IS NOT NULL AND bundle_id <> ? AND status = 'active'", + 'ii', + [$channelId, $bundleId] + ); + $this->execute( + "INSERT INTO release_channel_versions ( + channel_id, frontend_version_id, api_version_id, deployment_id, + service_set_id, bundle_id, actor_user_id, active + ) VALUES (?, ?, ?, ?, ?, ?, ?, 1)", + 'iiiiiii', + [$channelId, $frontendVersionId, $apiVersionId, $deploymentId, $serviceSetId, $bundleId, $actorUserId] + ); + + $this->execute( + "UPDATE release_bundles SET status = 'promoted', promoted_at = NOW() WHERE id = ?", + 'i', + [$bundleId] + ); + foreach ($this->selectRows('SELECT id, app FROM release_deployments WHERE bundle_id = ?', 'i', [$bundleId]) as $bundleDeployment) { + $bundleDeploymentId = (int)($bundleDeployment['id'] ?? 0); + $app = (string)($bundleDeployment['app'] ?? ''); + if ($bundleDeploymentId > 0 && in_array($app, self::APPS, true)) { + $this->activateDeploymentForChannelApp($bundleDeploymentId, $channelId, $app); + } + } + foreach ([$frontendVersionId, $apiVersionId] as $versionId) { + if ($versionId !== null) { + $this->execute( + "UPDATE release_versions SET status = 'active', deployed_at = COALESCE(deployed_at, NOW()) WHERE id = ?", + 'i', + [$versionId] + ); + } + } + + $this->audit($channelId, $deploymentId, 'bundle_promoted', $actorUserId, 'info', [ + 'bundle_id' => $bundleId, + 'service_set_id' => $serviceSetId, + 'data_promotion' => false, + 'replica_failover' => false, + ]); + + return [ + 'channel' => $this->publicChannel($this->getChannel($channelId)), + 'versions' => $this->currentVersionsForChannel($channelId), + 'service_set' => $this->publicServiceSet($this->getServiceSet($serviceSetId)), + 'bundle' => $this->publicBundle($this->getBundle($bundleId)), + ]; + } + + public function setChannelBundle(int $channelId, array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $channel = $this->getChannel($channelId); + $bundleId = $this->nullablePositiveInt($input['bundle_id'] ?? null); + if ($bundleId === null) { + throw new RuntimeException('A release bundle is required.'); + } + + $bundle = $this->getBundle($bundleId); + if ((int)$bundle['channel_id'] !== (int)$channel['id']) { + throw new RuntimeException('Release bundle does not belong to this channel.'); + } + + $status = strtolower((string)($bundle['status'] ?? '')); + if (!in_array($status, ['deployed', 'promoted', 'active'], true)) { + throw new RuntimeException('Only deployed release bundles can be set on a channel.'); + } + + $previous = $this->currentChannelVersionRow($channelId); + $result = $this->promoteBundle($bundleId, $actorUserId); + $this->audit($channelId, null, 'channel_bundle_set', $actorUserId, 'info', [ + 'bundle_id' => $bundleId, + 'previous_bundle_id' => $this->nullablePositiveInt($previous['bundle_id'] ?? null), + ]); + + return $result; + } + + public function listDeployments(int $limit = 50): array + { + if (!release_manager_schema_bootstrap::tablesExist()) { + return []; + } + + $limit = max(1, min(250, $limit)); + return array_map( + fn(array $row): array => $this->publicDeployment($row), + $this->selectRows( + "SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url + FROM release_deployments d + INNER JOIN release_channels c ON c.id = d.channel_id + LEFT JOIN release_versions v ON v.id = d.version_id + ORDER BY d.created_at DESC + LIMIT $limit" + ) + ); + } + + public function startDeployment(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $channel = $this->channelFromInput($input); + if ($this->channelUsesProductionServices($channel)) { + throw new RuntimeException('Beta release channel uses production services and cannot deploy separate frontend or API services.'); + } + $app = $this->normalizeApp((string)($input['app'] ?? '')); + $target = $this->deploymentTargetFromInput($input, (int)$channel['id'], $app); + + $repository = trim((string)($input['repository'] ?? $target['repository'] ?? '')); + $branch = trim((string)($input['branch'] ?? $target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH; + $normalizedRepository = self::normalizeGithubRepositoryName($repository); + if ($normalizedRepository !== '') { + $repository = $normalizedRepository; + } + $rawCommitSha = trim((string)($input['commit_sha'] ?? $input['commit'] ?? '')); + $commitMode = $this->normalizeCommitMode((string)($input['commit_mode'] ?? ''), $rawCommitSha); + $commitSha = $commitMode === 'specific' && $rawCommitSha !== '' ? $rawCommitSha : null; + $githubAccess = null; + if ($repository !== '') { + $githubAccess = $this->githubRepositoryAccess([ + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'commit_mode' => $commitMode, + ]); + if (($githubAccess['token_configured'] ?? false) && !($githubAccess['ok'] ?? false)) { + throw new RuntimeException('GitHub repository access test failed: ' . (string)($githubAccess['message'] ?? 'Repository is not accessible.')); + } + if (($githubAccess['ok'] ?? false) && !empty($githubAccess['commit_sha'])) { + $commitSha = (string)$githubAccess['commit_sha']; + $branch = (string)($githubAccess['branch'] ?? $branch); + } + } + $versionLabel = trim((string)($input['version_label'] ?? $input['tag'] ?? $commitSha ?? date('Ymd-His'))) ?: date('Ymd-His'); + $targetPublicUrl = is_array($target) ? $this->releaseTargetPublicBaseUrl($target) : null; + $deployedUrl = $this->normalizeReleasePublicBaseUrl( + $input['deployed_url'] ?? $targetPublicUrl ?? $target['health_url'] ?? null, + $app + ); + + $versionId = $this->nullablePositiveInt($input['version_id'] ?? null); + if ($versionId !== null) { + $version = $this->getVersion($versionId); + if ((string)($version['app'] ?? '') !== $app) { + throw new RuntimeException('Release bundle version does not match the deployment app.'); + } + $this->execute( + "UPDATE release_versions + SET repository = COALESCE(NULLIF(?, ''), repository), + branch = COALESCE(NULLIF(?, ''), branch), + commit_sha = COALESCE(?, commit_sha), + version_label = COALESCE(NULLIF(?, ''), version_label), + deployed_url = COALESCE(?, deployed_url), + status = 'deploying' + WHERE id = ?", + 'sssssi', + [$repository, $branch, $commitSha, $versionLabel, $deployedUrl, $versionId] + ); + } else { + $versionId = $this->createVersion([ + 'app' => $app, + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'tag' => trim((string)($input['tag'] ?? '')) ?: null, + 'version_label' => $versionLabel, + 'build_url' => trim((string)($input['build_url'] ?? '')) ?: null, + 'artifact_url' => trim((string)($input['artifact_url'] ?? '')) ?: null, + 'deployed_url' => $deployedUrl, + 'status' => 'deploying', + 'metadata' => is_array($input['metadata'] ?? null) ? $input['metadata'] : [], + ]); + } + + $requestedPayload = self::redactPayload($input); + if (is_array($requestedPayload)) { + $requestedPayload['commit_mode'] = $commitMode; + $requestedPayload['github_access'] = $githubAccess; + } + $targetId = isset($target['id']) ? (int)$target['id'] : null; + $serviceSetId = $this->nullablePositiveInt($input['service_set_id'] ?? null); + $bundleId = $this->nullablePositiveInt($input['bundle_id'] ?? null); + $deploymentKind = self::safeIdentifier((string)($input['deployment_kind'] ?? 'single_app'), 32) ?: 'single_app'; + $this->execute( + "INSERT INTO release_deployments ( + channel_id, target_id, version_id, service_set_id, bundle_id, deployment_kind, app, provider, repository, branch, + commit_sha, status, actor_user_id, requested_payload_json, started_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'coolify', ?, ?, ?, 'deploying', ?, ?, NOW())", + 'iiiiisssssis', + [ + (int)$channel['id'], + $targetId, + $versionId, + $serviceSetId, + $bundleId, + $deploymentKind, + $app, + $repository, + $branch, + $commitSha, + $actorUserId, + self::jsonEncode($requestedPayload), + ] + ); + $deploymentId = $this->insertId(); + + try { + $result = ['message' => 'Deployment recorded; no Coolify service target is configured.']; + $status = 'queued'; + if ($target !== null && !empty($target['coolify_instance_id'])) { + $coolifyTarget = array_replace($target, [ + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha ?? '', + ]); + $result = $this->deployCoolifyReleaseTarget($coolifyTarget); + $status = 'deployed'; + } + $effectiveDeployedUrl = $this->normalizeReleasePublicBaseUrl($result['public_url'] ?? $deployedUrl, $app); + + $this->execute( + "UPDATE release_deployments + SET status = ?, result_json = ?, deployment_url = ?, completed_at = CASE WHEN ? = 'deployed' THEN NOW() ELSE NULL END + WHERE id = ?", + 'ssssi', + [$status, self::jsonEncode(self::redactPayload($result)), $effectiveDeployedUrl, $status, $deploymentId] + ); + $this->execute( + "UPDATE release_versions + SET status = ?, + deployed_url = COALESCE(?, deployed_url), + deployed_at = CASE WHEN ? = 'deployed' THEN NOW() ELSE deployed_at END + WHERE id = ?", + 'sssi', + [$status === 'deployed' ? 'deployed' : 'deploying', $effectiveDeployedUrl, $status, $versionId] + ); + $this->audit((int)$channel['id'], $deploymentId, 'deployment_started', $actorUserId, 'info', [ + 'app' => $app, + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'commit_mode' => $commitMode, + 'github_access_status' => $githubAccess['status'] ?? null, + 'status' => $status, + ]); + } catch (Throwable $throwable) { + $failureSummary = self::deploymentFailureSummary($throwable, [ + 'app' => $app, + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'target_id' => $targetId, + 'coolify_instance_id' => is_array($target) ? ($target['coolify_instance_id'] ?? null) : null, + 'coolify_service_uuid' => is_array($target) ? ($target['coolify_service_uuid'] ?? null) : null, + ]); + $failureResult = [ + 'message' => 'Deployment failed before promotion. A successful deployment is required before promotion.', + 'failure_summary' => $failureSummary, + ]; + $this->execute( + "UPDATE release_deployments SET status = 'failed', result_json = ?, error_message = ?, completed_at = NOW() WHERE id = ?", + 'ssi', + [self::jsonEncode($failureResult), $throwable->getMessage(), $deploymentId] + ); + $this->execute("UPDATE release_versions SET status = 'failed' WHERE id = ?", 'i', [$versionId]); + $this->audit((int)$channel['id'], $deploymentId, 'deployment_failed', $actorUserId, 'error', [ + 'error' => $throwable->getMessage(), + 'failure_summary' => $failureSummary, + 'app' => $app, + ]); + } + + return $this->publicDeployment($this->getDeployment($deploymentId)); + } + + public function promoteDeployment(int $deploymentId, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $deployment = $this->getDeployment($deploymentId); + if (!self::deploymentCanBePromoted((string)($deployment['status'] ?? ''))) { + throw new RuntimeException(self::deploymentPromotionBlockedReason($deployment)); + } + $versionId = (int)($deployment['version_id'] ?? 0); + if ($versionId <= 0) { + throw new RuntimeException('Deployment has no release version to promote.'); + } + + $channelId = (int)$deployment['channel_id']; + if ($this->channelUsesProductionServices($this->getChannel($channelId))) { + throw new RuntimeException('Beta release channel uses production services and does not promote separate deployments.'); + } + $this->assertReleaseGatePassedForPromotion( + $channelId, + (string)($deployment['commit_sha'] ?? ''), + null, + (string)($deployment['app'] ?? '') + ); + $current = $this->currentChannelVersionRow($channelId); + $frontendVersionId = (int)($current['frontend_version_id'] ?? 0) ?: null; + $apiVersionId = (int)($current['api_version_id'] ?? 0) ?: null; + if ((string)$deployment['app'] === 'frontend') { + $frontendVersionId = $versionId; + } else { + $apiVersionId = $versionId; + } + + $this->execute('UPDATE release_channel_versions SET active = 0 WHERE channel_id = ?', 'i', [$channelId]); + $this->execute( + "INSERT INTO release_channel_versions (channel_id, frontend_version_id, api_version_id, deployment_id, actor_user_id, active) + VALUES (?, ?, ?, ?, ?, 1)", + 'iiiii', + [$channelId, $frontendVersionId, $apiVersionId, $deploymentId, $actorUserId] + ); + $this->activateDeploymentForChannelApp($deploymentId, $channelId, (string)$deployment['app']); + $this->execute("UPDATE release_versions SET status = 'active', deployed_at = COALESCE(deployed_at, NOW()) WHERE id = ?", 'i', [$versionId]); + + $this->audit($channelId, $deploymentId, 'deployment_promoted', $actorUserId, 'info', [ + 'app' => $deployment['app'], + 'version_id' => $versionId, + ]); + + return [ + 'channel' => $this->publicChannel($this->getChannel($channelId)), + 'versions' => $this->currentVersionsForChannel($channelId), + 'deployment' => $this->publicDeployment($this->getDeployment($deploymentId)), + ]; + } + + public function rollbackChannel(int $channelId, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $channel = $this->getChannel($channelId); + $previous = $this->selectOne( + "SELECT * FROM release_channel_versions + WHERE channel_id = ? AND active = 0 + ORDER BY activated_at DESC, id DESC + LIMIT 1", + 'i', + [$channelId] + ); + if ($previous === null) { + throw new RuntimeException('No previous release version exists for this channel.'); + } + + $this->execute('UPDATE release_channel_versions SET active = 0 WHERE channel_id = ?', 'i', [$channelId]); + $this->execute( + "INSERT INTO release_channel_versions ( + channel_id, frontend_version_id, api_version_id, deployment_id, + service_set_id, bundle_id, actor_user_id, active + ) VALUES (?, ?, ?, ?, ?, ?, ?, 1)", + 'iiiiiii', + [ + $channelId, + (int)($previous['frontend_version_id'] ?? 0) ?: null, + (int)($previous['api_version_id'] ?? 0) ?: null, + (int)($previous['deployment_id'] ?? 0) ?: null, + (int)($previous['service_set_id'] ?? 0) ?: null, + (int)($previous['bundle_id'] ?? 0) ?: null, + $actorUserId, + ] + ); + foreach (self::APPS as $app) { + $versionId = $this->nullablePositiveInt($previous[$app . '_version_id'] ?? null); + if ($versionId === null) { + continue; + } + $deployment = $this->selectOne( + "SELECT id FROM release_deployments + WHERE channel_id = ? AND app = ? AND version_id = ? + ORDER BY completed_at DESC, id DESC + LIMIT 1", + 'isi', + [$channelId, $app, $versionId] + ); + if ($deployment !== null) { + $this->activateDeploymentForChannelApp((int)$deployment['id'], $channelId, $app); + } + $this->execute( + "UPDATE release_versions SET status = 'active', deployed_at = COALESCE(deployed_at, NOW()) WHERE id = ?", + 'i', + [$versionId] + ); + } + + $this->audit($channelId, (int)($previous['deployment_id'] ?? 0) ?: null, 'channel_rolled_back', $actorUserId, 'warning', [ + 'previous_channel_version_id' => $previous['id'] ?? null, + 'frontend_version_id' => $this->nullablePositiveInt($previous['frontend_version_id'] ?? null), + 'api_version_id' => $this->nullablePositiveInt($previous['api_version_id'] ?? null), + 'service_set_id' => $this->nullablePositiveInt($previous['service_set_id'] ?? null), + 'bundle_id' => $this->nullablePositiveInt($previous['bundle_id'] ?? null), + 'public_smoke_required' => true, + ]); + + return [ + 'channel' => $this->publicChannel($channel), + 'versions' => $this->currentVersionsForChannel($channelId), + ]; + } + + public function setReplayTarget(array $input, ?int $actorUserId = null): array + { + $this->ensureSchema(); + $targetType = strtolower(trim((string)($input['target_type'] ?? ''))); + if (!in_array($targetType, ['user', 'subuser', 'customer', 'channel'], true)) { + throw new RuntimeException('Invalid replay target type.'); + } + + $targetId = trim((string)($input['target_id'] ?? '')) ?: null; + $channel = null; + if ($targetType === 'channel' || isset($input['channel_id']) || isset($input['channel_slug'])) { + $channel = $this->channelFromInput($input); + $targetId = $targetId ?: (string)$channel['slug']; + } + + $captureLevel = $this->normalizeCaptureLevel((string)($input['capture_level'] ?? 'full_redacted')); + $enabled = $this->toBool($input['enabled'] ?? true) ? 1 : 0; + $expiresAt = $this->normalizeDateTime($input['expires_at'] ?? null); + + $this->execute( + "INSERT INTO release_replay_targets (target_type, target_id, channel_id, capture_level, enabled, expires_at, actor_user_id) + VALUES (?, ?, ?, ?, ?, ?, ?)", + 'ssisisi', + [$targetType, $targetId, $channel['id'] ?? null, $captureLevel, $enabled, $expiresAt, $actorUserId] + ); + + $id = $this->insertId(); + $this->audit($channel !== null ? (int)$channel['id'] : null, null, 'replay_target_created', $actorUserId, 'warning', [ + 'target_type' => $targetType, + 'target_id' => $targetId, + 'capture_level' => $captureLevel, + 'enabled' => (bool)$enabled, + ]); + + return $this->selectOne('SELECT * FROM release_replay_targets WHERE id = ?', 'i', [$id]) ?? []; + } + + public function ingestTimelineEvents(array $events, array $context = [], bool $ensureSchema = true): array + { + if ($ensureSchema) { + $this->ensureSchema(); + } + if ($events === [] || !isset($events[0])) { + $events = [$events]; + } + + $traceId = self::safeIdentifier((string)($context['trace_id'] ?? $this->requestTraceId()), 64); + if ($traceId === '') { + $traceId = $this->requestTraceId(); + } + + $principalContext = $this->currentPrincipalContext(); + $context = array_replace($principalContext, array_filter($context, static fn(mixed $value): bool => $value !== null && $value !== '')); + + $channelSlug = self::safeSlug((string)($context['channel_slug'] ?? $context['release_channel'] ?? '')); + $channel = $channelSlug !== '' ? $this->findChannelBySlug($channelSlug) : null; + if ($channel === null) { + $channel = $this->resolveChannel($context); + } + if (empty($context['route_path']) && empty($context['route'])) { + foreach (array_reverse($events) as $eventForRoute) { + if (!is_array($eventForRoute)) { + continue; + } + $route = trim((string)($eventForRoute['route_path'] ?? $eventForRoute['route'] ?? '')); + if ($route !== '') { + $context['route_path'] = $route; + break; + } + } + } + $sessionId = $this->timelineSessionId($traceId, $context, $channel); + $accepted = 0; + + foreach ($events as $event) { + if (!is_array($event)) { + continue; + } + $eventType = self::safeIdentifier((string)($event['type'] ?? $event['event_type'] ?? 'event'), 64) ?: 'event'; + $severity = self::safeIdentifier((string)($event['severity'] ?? 'info'), 16) ?: 'info'; + $payload = self::redactPayload($event['payload'] ?? $event); + $occurredAt = $this->normalizeDateTime($event['occurred_at'] ?? null) ?? date('Y-m-d H:i:s'); + + $this->execute( + "INSERT INTO release_timeline_events ( + timeline_session_id, trace_id, event_type, severity, module_key, + route_path, component, request_id, occurred_at, payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + 'isssssssss', + [ + $sessionId, + $traceId, + $eventType, + $severity, + self::safeIdentifier((string)($event['module_key'] ?? ''), 64) ?: null, + trim((string)($event['route'] ?? $event['route_path'] ?? '')) ?: null, + trim((string)($event['component'] ?? '')) ?: null, + trim((string)($event['request_id'] ?? '')) ?: null, + $occurredAt, + self::jsonEncode($payload), + ] + ); + $accepted++; + } + + return ['accepted' => $accepted, 'trace_id' => $traceId, 'timeline_session_id' => $sessionId]; + } + + public function searchTimeline(array $filters = []): array + { + $this->ensureSchema(); + $this->cleanupExpiredReplayData(); + $types = ''; + $params = []; + $where = ['1 = 1']; + + foreach ([ + 'trace_id' => 'e.trace_id', + 'event_type' => 'e.event_type', + 'severity' => 'e.severity', + 'module_key' => 'e.module_key', + 'channel_slug' => 's.channel_slug', + 'principal_type' => 's.principal_type', + 'principal_id' => 's.principal_id', + ] as $filterKey => $column) { + $value = trim((string)($filters[$filterKey] ?? '')); + if ($value === '') { + continue; + } + $where[] = "$column = ?"; + $types .= 's'; + $params[] = $value; + } + + if (isset($filters['customer_number']) && is_numeric($filters['customer_number'])) { + $where[] = 's.customer_number = ?'; + $types .= 'i'; + $params[] = (int)$filters['customer_number']; + } + + $limit = max(1, min(500, (int)($filters['limit'] ?? 100))); + $rows = $this->selectRows( + "SELECT e.*, s.principal_type, s.principal_id, s.customer_number, s.channel_slug + FROM release_timeline_events e + LEFT JOIN release_timeline_sessions s ON s.id = e.timeline_session_id + WHERE " . implode(' AND ', $where) . " + ORDER BY e.occurred_at DESC, e.id DESC + LIMIT $limit", + $types, + $params + ); + + return array_map(fn(array $row): array => $this->publicTimelineEvent($row), $rows); + } + + public function listTimelineSessions(array $filters = []): array + { + $this->ensureSchema(); + $this->cleanupExpiredReplayData(); + + $types = ''; + $params = []; + $where = ['1 = 1']; + + foreach ([ + 'trace_id' => 's.trace_id', + 'principal_type' => 's.principal_type', + 'principal_id' => 's.principal_id', + 'device_type' => 's.device_type', + 'channel_slug' => 's.channel_slug', + 'frontend_version' => 's.frontend_version_label', + 'api_version' => 's.api_version_label', + 'event_type' => 'e.event_type', + 'severity' => 'e.severity', + 'module_key' => 'e.module_key', + ] as $filterKey => $column) { + $value = trim((string)($filters[$filterKey] ?? '')); + if ($value === '') { + continue; + } + $where[] = "$column = ?"; + $types .= 's'; + $params[] = $value; + } + + if (isset($filters['customer_number']) && is_numeric($filters['customer_number'])) { + $where[] = 's.customer_number = ?'; + $types .= 'i'; + $params[] = (int)$filters['customer_number']; + } + + foreach (['date_from' => '>=', 'date_to' => '<='] as $filterKey => $operator) { + $date = $this->normalizeDateTime($filters[$filterKey] ?? null); + if ($date === null) { + continue; + } + $where[] = "s.last_seen_at $operator ?"; + $types .= 's'; + $params[] = $date; + } + + $hasErrorReport = $this->toBool($filters['has_error_report'] ?? false); + $hasErrorReportTable = $this->tableExists('error_reports'); + if ($hasErrorReport && $hasErrorReportTable) { + $where[] = 'EXISTS (SELECT 1 FROM error_reports er_filter WHERE er_filter.release_trace_id = s.trace_id)'; + } elseif ($hasErrorReport) { + $where[] = '1 = 0'; + } + + $errorReportCountSelect = $hasErrorReportTable + ? "(SELECT COUNT(*) FROM error_reports er_count WHERE er_count.release_trace_id = s.trace_id) AS error_report_count" + : '0 AS error_report_count'; + + $limit = max(1, min(500, (int)($filters['limit'] ?? 100))); + $rows = $this->selectRows( + "SELECT + s.*, + COUNT(e.id) AS event_count, + SUM(CASE WHEN e.severity = 'error' THEN 1 ELSE 0 END) AS error_count, + MIN(e.occurred_at) AS first_event_at, + MAX(e.occurred_at) AS last_event_at, + GROUP_CONCAT(DISTINCT e.module_key ORDER BY e.module_key SEPARATOR ',') AS module_keys, + $errorReportCountSelect + FROM release_timeline_sessions s + LEFT JOIN release_timeline_events e ON e.timeline_session_id = s.id + WHERE " . implode(' AND ', $where) . " + GROUP BY s.id + ORDER BY COALESCE(MAX(e.occurred_at), s.last_seen_at) DESC, s.id DESC + LIMIT $limit", + $types, + $params + ); + + return array_map(fn(array $row): array => $this->publicTimelineSession($row), $rows); + } + + public function timelineSessionDetail(string $traceId): array + { + $this->ensureSchema(); + $this->cleanupExpiredReplayData(); + + $traceId = self::safeIdentifier($traceId, 64); + if ($traceId === '') { + throw new RuntimeException('Invalid timeline trace id.'); + } + + $session = $this->selectOne( + 'SELECT * FROM release_timeline_sessions WHERE trace_id = ? LIMIT 1', + 's', + [$traceId] + ); + if ($session === null) { + throw new RuntimeException('Timeline session not found.'); + } + + $events = $this->selectRows( + "SELECT e.*, s.principal_type, s.principal_id, s.customer_number, s.channel_slug + FROM release_timeline_events e + LEFT JOIN release_timeline_sessions s ON s.id = e.timeline_session_id + WHERE e.trace_id = ? + ORDER BY e.occurred_at ASC, e.id ASC", + 's', + [$traceId] + ); + + $channel = isset($session['channel_id']) ? $this->selectOne('SELECT * FROM release_channels WHERE id = ? LIMIT 1', 'i', [(int)$session['channel_id']]) : null; + + return [ + 'session' => $this->publicTimelineSession($session), + 'events' => array_map(fn(array $row): array => $this->publicTimelineEvent($row), $events), + 'error_reports' => $this->timelineErrorReports($traceId), + 'release' => $this->timelineReleaseContext($session, $channel), + ]; + } + + public function handleGithubWebhook(array $headers, string $rawBody): array + { + $this->ensureSchema(); + $secret = (string)$this->moduleConfigValue('ReleaseManager', 'github_webhook_secret', ''); + $signature = self::headerValue($headers, 'X-Hub-Signature-256'); + if (!self::verifyGithubSignature($secret, $rawBody, $signature)) { + throw new RuntimeException('Invalid GitHub webhook signature.'); + } + + $event = self::headerValue($headers, 'X-GitHub-Event') ?: 'unknown'; + $payload = json_decode($rawBody, true); + if (!is_array($payload)) { + throw new RuntimeException('Invalid GitHub webhook JSON payload.'); + } + + if ($event !== 'push') { + $this->audit(null, null, 'github_webhook_ignored', null, 'info', ['event' => $event]); + return ['event' => $event, 'deployments' => [], 'ignored' => true]; + } + + $repository = (string)($payload['repository']['full_name'] ?? $payload['repository']['name'] ?? ''); + $branch = preg_replace('#^refs/heads/#', '', (string)($payload['ref'] ?? '')); + $commitSha = (string)($payload['after'] ?? ''); + if ($repository === '' || $branch === '' || $commitSha === '') { + throw new RuntimeException('GitHub push payload is missing repository, branch, or commit.'); + } + + $normalizedRepository = self::normalizeGithubRepositoryName($repository); + if ($normalizedRepository !== '') { + $repository = $normalizedRepository; + } + + $mappedChannelSlug = self::channelSlugForRoute($branch); + $mappedChannel = $this->findChannelBySlug($mappedChannelSlug); + $mappedApp = ''; + if ($repository === self::defaultRepositoryForApp('frontend')) { + $mappedApp = 'frontend'; + } elseif ($repository === self::defaultRepositoryForApp('api')) { + $mappedApp = 'api'; + } + + $targets = $this->selectRows( + "SELECT * FROM release_deployment_targets + WHERE deleted_at IS NULL AND auto_deploy = 1 AND repository = ? AND branch = ?", + 'ss', + [$repository, $branch] + ); + + $autoSyncEvents = []; + foreach ($targets as $target) { + $autoSyncEvents[] = $this->publicReleaseAutoSyncEvent($this->upsertReleaseAutoSyncEvent([ + 'channel_id' => (int)$target['channel_id'], + 'app' => (string)$target['app'], + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'status' => 'pending', + 'source' => 'github_webhook', + 'workflow_url' => (string)($payload['compare'] ?? ''), + 'metadata' => [ + 'github_event' => $event, + 'head_commit' => self::redactPayload($payload['head_commit'] ?? []), + ], + ])); + } + + $this->audit(null, null, 'github_webhook_processed', null, 'info', [ + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'auto_sync_event_count' => count($autoSyncEvents), + ]); + + return [ + 'event' => $event, + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'mapped_channel_slug' => $mappedChannelSlug, + 'mapped_app' => $mappedApp !== '' ? $mappedApp : null, + 'auto_sync_events' => $autoSyncEvents, + 'operations' => [], + 'deployments' => [], + ]; + } + + public function healthProbe(): array + { + $startedAt = microtime(true); + try { + $summary = $this->summary(); + $channels = $summary['channels'] ?? []; + $deployments = $summary['deployments'] ?? []; + $failedDeployments = array_values(array_filter($deployments, static fn(array $deployment): bool => ($deployment['status'] ?? '') === 'failed')); + $channelsWithoutVersions = array_values(array_filter($channels, static function (array $channel): bool { + if (($channel['enabled'] ?? false) !== true) { + return false; + } + $versions = $channel['versions'] ?? []; + return empty($versions['frontend']) && empty($versions['api']); + })); + + $status = 'ok'; + $reason = 'Release channels and deployment telemetry are available.'; + $reasonKey = 'release_manager_available'; + if ($failedDeployments !== []) { + $status = 'degraded'; + $reason = 'One or more recent release deployments failed.'; + $reasonKey = 'release_deployments_failed'; + } elseif ($channelsWithoutVersions !== []) { + $status = 'degraded'; + $reason = 'One or more enabled release channels have no active versions yet.'; + $reasonKey = 'release_channels_without_versions'; + } + + return [ + 'status' => $status, + 'status_reason' => $reason, + 'status_reason_key' => $reasonKey, + 'status_reason_params' => [ + 'channels' => count($channels), + 'deployment_targets' => count($summary['deployment_targets'] ?? []), + 'recent_failed_deployments' => count($failedDeployments), + ], + 'checked_at' => date('c'), + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + ]; + } catch (Throwable $throwable) { + return [ + 'status' => 'down', + 'status_reason' => 'Release manager probe failed: ' . $throwable->getMessage(), + 'status_reason_key' => 'release_manager_probe_failed', + 'status_reason_params' => ['error' => $throwable->getMessage()], + 'checked_at' => date('c'), + 'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2), + ]; + } + } + + private function ensureSchema(): void + { + if ($this->schemaEnsured) { + return; + } + release_manager_schema_bootstrap::ensureTables(); + $this->schemaEnsured = true; + } + + private function resolveChannel(array $context): array + { + $cacheKey = $this->assignmentCacheKey($context); + if ($cacheKey !== '' && defined('redis')) { + try { + $cached = redis->get($cacheKey); + if (is_string($cached) && $cached !== '') { + $decoded = json_decode($cached, true); + if (is_array($decoded) && !empty($decoded['id'])) { + return $decoded; + } + } + } catch (Throwable) { + } + } + + $candidates = []; + if (!empty($context['principal_type']) && !empty($context['principal_id'])) { + $candidates[] = [(string)$context['principal_type'], (string)$context['principal_id']]; + } + if (!empty($context['customer_number'])) { + $candidates[] = ['customer', (string)(int)$context['customer_number']]; + } + + foreach ($candidates as [$subjectType, $subjectId]) { + $row = $this->selectOne( + "SELECT c.* + FROM release_assignments a + INNER JOIN release_channels c ON c.id = a.channel_id + WHERE a.deleted_at IS NULL + AND c.deleted_at IS NULL + AND c.enabled = 1 + AND a.subject_type = ? + AND a.subject_id = ? + AND (a.expires_at IS NULL OR a.expires_at > NOW()) + ORDER BY a.created_at DESC, a.id DESC + LIMIT 1", + 'ss', + [$subjectType, $subjectId] + ); + if ($row !== null) { + $this->cacheResolvedChannel($cacheKey, $row); + return $row; + } + } + + $rolloutChannel = $this->rolloutChannelForContext($context); + if ($rolloutChannel !== null) { + $this->cacheResolvedChannel($cacheKey, $rolloutChannel); + return $rolloutChannel; + } + + $default = $this->defaultChannel(); + $this->cacheResolvedChannel($cacheKey, $default); + return $default; + } + + private function runtimeChannelsForContext(array $context, ?array $resolvedChannel = null): array + { + $channelsByKey = []; + + $this->addRuntimeChannel($channelsByKey, $this->defaultChannel()); + + foreach ($this->assignmentCandidates($context) as [$subjectType, $subjectId]) { + $rows = $this->selectRows( + "SELECT c.* + FROM release_assignments a + INNER JOIN release_channels c ON c.id = a.channel_id + WHERE a.deleted_at IS NULL + AND c.deleted_at IS NULL + AND c.enabled = 1 + AND a.subject_type = ? + AND a.subject_id = ? + AND (a.expires_at IS NULL OR a.expires_at > NOW()) + ORDER BY a.created_at DESC, a.id DESC", + 'ss', + [$subjectType, $subjectId] + ); + + foreach ($rows as $row) { + $this->addRuntimeChannel($channelsByKey, $row); + } + } + + if ($resolvedChannel !== null) { + $this->addRuntimeChannel($channelsByKey, $resolvedChannel); + } + + return array_values($channelsByKey); + } + + private function assignmentCandidates(array $context): array + { + $candidates = []; + if (!empty($context['principal_type']) && !empty($context['principal_id'])) { + $candidates[] = [(string)$context['principal_type'], (string)$context['principal_id']]; + } + if (!empty($context['customer_number'])) { + $candidates[] = ['customer', (string)(int)$context['customer_number']]; + } + return $candidates; + } + + private function addRuntimeChannel(array &$channelsByKey, array $channel): void + { + $id = (int)($channel['id'] ?? 0); + $slug = self::safeSlug((string)($channel['slug'] ?? '')); + $key = $id > 0 ? 'id:' . $id : ($slug !== '' ? 'slug:' . $slug : ''); + if ($key === '' || isset($channelsByKey[$key])) { + return; + } + $channelsByKey[$key] = $channel; + } + + private function chooseRuntimeChannel(array $resolvedChannel, array $availableChannels, string $requestedSlug): array + { + if ($requestedSlug === '') { + return $resolvedChannel; + } + + foreach ($availableChannels as $channel) { + if (self::safeSlug((string)($channel['slug'] ?? '')) === $requestedSlug) { + return $channel; + } + } + + return $resolvedChannel; + } + + private function runtimeServiceChannelFor(array $channel): array + { + if (!$this->channelUsesProductionServices($channel)) { + return $channel; + } + + return $this->productionServiceChannel(); + } + + private function productionServiceChannel(): array + { + $channel = $this->selectOne( + "SELECT * + FROM release_channels + WHERE deleted_at IS NULL + AND enabled = 1 + AND default_channel = 1 + AND slug <> 'beta' + ORDER BY id + LIMIT 1" + ); + if ($channel !== null) { + return $channel; + } + + foreach (self::BETA_PRODUCTION_DATA_SOURCE_CHANNELS as $slug) { + $channel = $this->findChannelBySlug($slug); + if ($channel !== null && (int)($channel['enabled'] ?? 0) === 1 && !$this->channelUsesProductionServices($channel)) { + return $channel; + } + } + + $default = $this->defaultChannel(); + if ($this->channelUsesProductionServices($default)) { + throw new RuntimeException('No production release channel is configured for beta services.'); + } + + return $default; + } + + private function requestedRuntimeChannelSlug(array $input = []): string + { + $value = $input['release_channel'] + ?? $input['channel_slug'] + ?? ($_GET['release_channel'] ?? $_GET['channel_slug'] ?? ''); + return self::safeSlug((string)$value); + } + + private function rolloutChannelForContext(array $context): ?array + { + $seed = (string)($context['principal_id'] ?? $context['customer_number'] ?? ''); + if ($seed === '') { + return null; + } + + $bucket = (hexdec(substr(hash('sha256', $seed), 0, 8)) % 10000) / 100; + $channels = $this->selectRows( + "SELECT * FROM release_channels + WHERE deleted_at IS NULL AND enabled = 1 AND default_channel = 0 AND rollout_percent > 0 + ORDER BY rollout_percent DESC, slug" + ); + + foreach ($channels as $channel) { + if ($bucket < (float)$channel['rollout_percent']) { + return $channel; + } + } + + return null; + } + + private function capturePolicyFor(array $context, array $channel): array + { + $enabled = (bool)((int)($channel['replay_enabled'] ?? 0)); + $captureLevel = $this->normalizeCaptureLevel((string)($channel['capture_level'] ?? 'metadata')); + $retentionDays = max(1, (int)($channel['retention_days'] ?? 14)); + + $targets = []; + if (!empty($context['principal_type']) && !empty($context['principal_id'])) { + $targets[] = [(string)$context['principal_type'], (string)$context['principal_id']]; + } + if (!empty($context['customer_number'])) { + $targets[] = ['customer', (string)(int)$context['customer_number']]; + } + $targets[] = ['channel', (string)$channel['slug']]; + + foreach ($targets as [$targetType, $targetId]) { + $row = $this->selectOne( + "SELECT capture_level + FROM release_replay_targets + WHERE deleted_at IS NULL + AND enabled = 1 + AND target_type = ? + AND (target_id = ? OR (target_type = 'channel' AND channel_id = ?)) + AND (expires_at IS NULL OR expires_at > NOW()) + ORDER BY created_at DESC, id DESC + LIMIT 1", + 'ssi', + [$targetType, $targetId, (int)$channel['id']] + ); + if ($row !== null) { + $enabled = true; + $captureLevel = $this->normalizeCaptureLevel((string)$row['capture_level']); + break; + } + } + + return [ + 'enabled' => $enabled, + 'capture_level' => $captureLevel, + 'all_failure_metadata' => true, + 'retention_days' => $retentionDays, + ]; + } + + private function currentPrincipalContext(): array + { + try { + $auth = new authentication(); + $subuser = $auth->get_subuser(); + if ($subuser !== false) { + return [ + 'principal_type' => 'subuser', + 'principal_id' => (string)$subuser->id, + 'customer_number' => $auth->get_subuser_customer_number_target() ?: null, + ]; + } + $user = $auth->get_user(); + if ($user !== false) { + return [ + 'principal_type' => 'user', + 'principal_id' => (string)$user->id, + 'customer_number' => isset($user->customer_number) ? (int)$user->customer_number->value() : null, + ]; + } + } catch (Throwable) { + } + + return [ + 'principal_type' => null, + 'principal_id' => null, + 'customer_number' => null, + ]; + } + + private function defaultChannel(): array + { + $channel = $this->selectOne( + "SELECT * FROM release_channels WHERE deleted_at IS NULL AND enabled = 1 AND default_channel = 1 ORDER BY id LIMIT 1" + ); + if ($channel !== null) { + return $channel; + } + + $channel = $this->selectOne( + "SELECT * FROM release_channels WHERE deleted_at IS NULL AND slug = 'stable' ORDER BY id LIMIT 1" + ); + if ($channel !== null) { + return $channel; + } + + throw new RuntimeException('No release channel is configured.'); + } + + private function currentVersionsForChannel(int $channelId): array + { + $current = $this->currentChannelVersionRow($channelId); + $frontend = null; + $api = null; + if (!empty($current['frontend_version_id'])) { + $frontend = $this->publicVersion($this->getVersion((int)$current['frontend_version_id'])); + } + if (!empty($current['api_version_id'])) { + $api = $this->publicVersion($this->getVersion((int)$current['api_version_id'])); + } + $serviceSet = null; + if (!empty($current['service_set_id'])) { + try { + $serviceSet = $this->publicServiceSet($this->getServiceSet((int)$current['service_set_id']), false); + } catch (Throwable) { + $serviceSet = null; + } + } + $bundle = null; + if (!empty($current['bundle_id'])) { + try { + $bundle = $this->publicBundle($this->getBundle((int)$current['bundle_id']), false); + } catch (Throwable) { + $bundle = null; + } + } + + return [ + 'frontend' => $frontend, + 'api' => $api, + 'service_set' => $serviceSet, + 'bundle_id' => isset($current['bundle_id']) ? (int)$current['bundle_id'] : null, + 'bundle' => $bundle, + ]; + } + + private function channelAvailability(array $channel): array + { + $channelId = (int)($channel['id'] ?? 0); + if ($this->channelUsesProductionServices($channel)) { + $serviceChannel = $this->productionServiceChannel(); + $versions = $this->currentVersionsForChannel((int)$serviceChannel['id']); + $urls = $this->releaseRuntimeUrls($serviceChannel, $versions); + $availability = $this->channelAvailability($serviceChannel); + + return array_replace($availability, [ + 'configured' => (bool)($availability['configured'] ?? false), + 'missing' => is_array($availability['missing'] ?? null) ? $availability['missing'] : [], + 'frontend_base_url' => $urls['frontend_base_url'], + 'api_base_url' => $urls['api_base_url'], + 'service_policy' => self::PRODUCTION_SERVICE_POLICY, + 'service_channel_id' => (int)($serviceChannel['id'] ?? 0), + 'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''), + ]); + } + + $isDefault = ((int)($channel['default_channel'] ?? 0) === 1) || (string)($channel['slug'] ?? '') === 'stable'; + if ($isDefault || $channelId <= 0) { + return [ + 'configured' => true, + 'missing' => [], + 'status' => 'ready', + ]; + } + + $versions = $this->currentVersionsForChannel($channelId); + $urls = $this->releaseRuntimeUrls($channel, $versions); + $missing = []; + if (empty($versions['frontend'])) { + $missing[] = 'frontend_version'; + } elseif (empty($urls['frontend_base_url'])) { + $missing[] = 'frontend_base_url'; + } + if (empty($versions['api'])) { + $missing[] = 'api_version'; + } elseif (empty($urls['api_base_url'])) { + $missing[] = 'api_base_url'; + } + + return [ + 'configured' => count($missing) === 0, + 'missing' => $missing, + 'bundle_id' => $versions['bundle_id'] ?? null, + 'frontend_base_url' => $urls['frontend_base_url'], + 'api_base_url' => $urls['api_base_url'], + 'status' => count($missing) === 0 ? 'ready' : 'unconfigured', + ]; + } + + private function releaseRuntimeUrls(array $channel, array $versions): array + { + $frontend = is_array($versions['frontend'] ?? null) ? $versions['frontend'] : []; + $api = is_array($versions['api'] ?? null) ? $versions['api'] : []; + $serviceSet = is_array($versions['service_set'] ?? null) ? $versions['service_set'] : []; + $targets = is_array($serviceSet['targets'] ?? null) ? $serviceSet['targets'] : []; + $frontendTarget = is_array($targets['frontend'] ?? null) ? $targets['frontend'] : null; + $apiTarget = is_array($targets['api'] ?? null) ? $targets['api'] : null; + + return [ + 'frontend_base_url' => $this->normalizeReleasePublicBaseUrl($frontend['deployed_url'] ?? null, 'frontend') + ?? $this->normalizeReleasePublicBaseUrl($channel['frontend_base_url'] ?? null, 'frontend') + ?? (is_array($frontendTarget) ? $this->releaseTargetPublicBaseUrl($frontendTarget) : null), + 'api_base_url' => $this->normalizeReleasePublicBaseUrl($api['deployed_url'] ?? null, 'api') + ?? $this->normalizeReleasePublicBaseUrl($channel['api_base_url'] ?? null, 'api') + ?? (is_array($apiTarget) ? $this->releaseTargetPublicBaseUrl($apiTarget) : null), + ]; + } + + private function normalizeReleasePublicBaseUrl(mixed $value, string $app = ''): ?string + { + $raw = trim((string)$value); + if ($raw === '') { + return null; + } + + $raw = preg_replace('#/(health|ping)$#i', '', rtrim($raw, '/')) ?: $raw; + if (preg_match('#^https?://#i', $raw) !== 1) { + $raw = 'https://' . ltrim($raw, '/'); + } + + $parts = parse_url($raw); + if (!is_array($parts) || empty($parts['host'])) { + return null; + } + + $scheme = strtolower((string)($parts['scheme'] ?? 'https')); + if (!in_array($scheme, ['http', 'https'], true)) { + return null; + } + + $path = isset($parts['path']) ? '/' . trim((string)$parts['path'], '/') : ''; + $port = isset($parts['port']) ? ':' . (int)$parts['port'] : ''; + return rtrim($scheme . '://' . strtolower((string)$parts['host']) . $port . $path, '/'); + } + + private function currentChannelVersionRow(int $channelId): ?array + { + return $this->selectOne( + "SELECT * FROM release_channel_versions + WHERE channel_id = ? AND active = 1 + ORDER BY activated_at DESC, id DESC + LIMIT 1", + 'i', + [$channelId] + ); + } + + private function createVersion(array $input): int + { + $this->execute( + "INSERT INTO release_versions ( + app, repository, branch, commit_sha, tag, version_label, + build_url, artifact_url, deployed_url, status, metadata_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + 'sssssssssss', + [ + $input['app'], + $input['repository'] ?? null, + $input['branch'] ?? null, + $input['commit_sha'] ?? null, + $input['tag'] ?? null, + $input['version_label'] ?? null, + $input['build_url'] ?? null, + $input['artifact_url'] ?? null, + $input['deployed_url'] ?? null, + $input['status'] ?? 'discovered', + self::jsonEncode($input['metadata'] ?? []), + ] + ); + return $this->insertId(); + } + + private function restartCoolifyService(int $instanceId, string $serviceUuid): array + { + $instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL', 'i', [$instanceId]); + if ($instance === null) { + throw new RuntimeException('Coolify instance for release deployment was not found.'); + } + $token = replication_secret_box::decrypt((string)$instance['api_token_secret']); + $client = new coolify_api_client((string)$instance['base_url'], $token, 20); + return $client->restartService($serviceUuid); + } + + private function deployCoolifyReleaseTarget(array $target): array + { + $instanceId = (int)($target['coolify_instance_id'] ?? 0); + $instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL', 'i', [$instanceId]); + if ($instance === null) { + throw new RuntimeException('Coolify instance for release deployment was not found.'); + } + + $token = replication_secret_box::decrypt((string)$instance['api_token_secret']); + $client = new coolify_api_client((string)$instance['base_url'], $token, 20); + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + $serviceUuid = trim((string)($target['coolify_service_uuid'] ?? '')); + $resourceType = $this->releaseCoolifyResourceType($context, $serviceUuid); + $created = null; + $publicUrl = $this->releaseCoolifyPublicUrl($target, $context); + $runtimeEnvUpdate = null; + + if ($serviceUuid === '' && $this->toBool($context['coolify_auto_create'] ?? false)) { + $githubAppUuid = $this->releaseCoolifyGithubAppUuid($context, $target, $instance, true); + if ($githubAppUuid !== '') { + $context['coolify_github_app_uuid'] = $githubAppUuid; + $applicationPayload = $this->releaseCoolifyApplicationPayload($target, $context, $instance); + $applicationPayload['instant_deploy'] = false; + $created = $client->createPrivateGithubAppApplication($applicationPayload); + $resourceType = 'application'; + } else { + if (self::releaseCoolifyPublicUrlNeedsStripPrefixLabels($publicUrl)) { + throw new RuntimeException('Path-routed release targets require a Coolify application resource with StripPrefix labels. Set coolify_resource_type=application or migrate this target before deploying.'); + } + if ($this->releaseCoolifyServiceSourceIsMissing($context)) { + throw new RuntimeException('Release Manager could not resolve a Coolify GitHub App for this private source repository. Configure one GitHub App in Coolify or set coolify_github_app_uuid on the target; no source-code credentials are required in Release Manager.'); + } + $servicePayload = $this->releaseCoolifyServicePayload($target, $context, $instance); + $servicePayload['instant_deploy'] = false; + $created = $client->createService($servicePayload); + $resourceType = 'service'; + } + $serviceUuid = trim((string)($created['uuid'] ?? '')); + if ($serviceUuid === '') { + throw new RuntimeException('Coolify did not return a resource UUID for the release target.'); + } + $context['coolify_resource_type'] = $resourceType; + $this->execute( + 'UPDATE release_deployment_targets SET coolify_service_uuid = ?, deploy_context_json = ? WHERE id = ?', + 'ssi', + [$serviceUuid, self::jsonEncode($context), (int)$target['id']] + ); + } + + if ($serviceUuid === '') { + throw new RuntimeException('Select an existing Coolify service or enable Coolify service creation before deployment.'); + } + + $update = null; + if ($resourceType === 'application') { + $applicationUpdate = $this->releaseCoolifyApplicationUpdatePayload($target, $context); + if ($this->toBool($context['coolify_enable_ssl'] ?? false) && $publicUrl !== null) { + $resource = []; + try { + $resource = $client->getApplication($serviceUuid); + } catch (Throwable) { + $resource = is_array($created) ? $created : []; + } + $applicationUpdate = array_replace( + $applicationUpdate, + $this->releaseCoolifyApplicationRoutePayload( + $target, + $context, + $publicUrl, + $serviceUuid, + $resource['custom_labels'] ?? null + ) + ); + } + if ($applicationUpdate !== []) { + $update = $client->updateApplication($serviceUuid, $applicationUpdate); + } + $runtimeEnvUpdate = $this->updateCoolifyReleaseRuntimeEnv($client, $serviceUuid, 'application', $target, $context); + } elseif ($this->toBool($context['coolify_enable_ssl'] ?? false) && $publicUrl !== null) { + if (self::releaseCoolifyPublicUrlNeedsStripPrefixLabels($publicUrl)) { + throw new RuntimeException('Path-routed release targets require a Coolify application resource with StripPrefix labels. Set coolify_resource_type=application or migrate this target before deploying.'); + } + $update = $client->updateService($serviceUuid, [ + 'urls' => [ + [ + 'name' => (string)($target['app'] ?? 'release'), + 'url' => self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context)), + ], + ], + 'force_domain_override' => true, + ]); + $runtimeEnvUpdate = $this->updateCoolifyReleaseRuntimeEnv($client, $serviceUuid, 'service', $target, $context); + } + + if ($runtimeEnvUpdate === null) { + $runtimeEnvUpdate = $this->updateCoolifyReleaseRuntimeEnv($client, $serviceUuid, $resourceType, $target, $context); + } + + $deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context)); + return [ + 'service_uuid' => $serviceUuid, + 'resource_type' => $resourceType, + 'ssl_enabled' => $this->toBool($context['coolify_enable_ssl'] ?? false), + 'public_url' => $publicUrl, + 'created' => self::redactPayload($created ?? []), + 'updated' => self::redactPayload($update ?? []), + 'runtime_env' => $runtimeEnvUpdate, + 'deployment' => self::redactPayload($deployment), + ]; + } + + private function updateCoolifyReleaseRuntimeEnv(coolify_api_client $client, string $resourceUuid, string $resourceType, array $target, array $context): ?array + { + $env = $this->releaseCoolifyRuntimeEnv($target, $context); + if ($env === []) { + return null; + } + + if ($resourceType === 'application') { + $client->updateApplicationEnvsBulk($resourceUuid, $env); + } else { + $client->updateServiceEnvsBulk($resourceUuid, $env); + } + + return [ + 'resource_type' => $resourceType, + 'count' => count($env), + 'keys' => array_keys($env), + ]; + } + + private function releaseCoolifyRuntimeEnv(array $target, array $context): array + { + $contextEnv = $this->releaseCoolifyContextEnv($context); + $env = $contextEnv; + $app = strtolower(trim((string)($target['app'] ?? ''))); + if ($app !== 'api') { + return $env; + } + + $env['USE_ENV'] = $env['USE_ENV'] ?? 'true'; + foreach (self::RELEASE_API_RUNTIME_ENV_KEYS as $key) { + $this->appendRuntimeEnvValue($env, $key); + } + + $runtime = array_replace( + is_array($_ENV ?? null) ? $_ENV : [], + is_array($_SERVER ?? null) ? $_SERVER : [], + is_array(getenv()) ? getenv() : [] + ); + foreach ($runtime as $key => $value) { + $key = (string)$key; + if (!$this->releaseRuntimeEnvKeyAllowed($key)) { + continue; + } + $this->appendRuntimeEnvValue($env, $key, $value); + } + + $deploymentCommitSha = self::normalizeCommitSha($this->releaseCoolifyGitCommitSha($target, $context)); + if ($deploymentCommitSha !== '') { + foreach (['API_COMMIT_SHA', 'COMMIT_SHA'] as $key) { + if (!array_key_exists($key, $contextEnv)) { + $env[$key] = $deploymentCommitSha; + } + } + } + + $env = array_replace($env, $contextEnv); + $env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true'; + $env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? '')); + return $this->normalizeCoolifyRuntimeEnv($env); + } + + private function releaseCoolifyContextEnv(array $context): array + { + $env = []; + foreach (['coolify_env', 'runtime_env', 'environment_variables'] as $key) { + if (is_array($context[$key] ?? null)) { + foreach ($context[$key] as $envKey => $value) { + $this->appendRuntimeEnvValue($env, (string)$envKey, $value); + } + } + } + + foreach (['coolify_env_file', 'env'] as $key) { + $raw = $context[$key] ?? null; + if (!is_string($raw) || trim($raw) === '') { + continue; + } + foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $line) { + $line = trim((string)$line); + if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) { + continue; + } + [$envKey, $value] = explode('=', $line, 2); + $this->appendRuntimeEnvValue($env, trim($envKey), $value); + } + } + + return $this->normalizeCoolifyRuntimeEnv($env); + } + + private function appendRuntimeEnvValue(array &$env, string $key, mixed $value = null): void + { + $key = trim($key); + if ($key === '' || !preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) { + return; + } + if ($value === null) { + $value = getenv($key); + if ($value === false && array_key_exists($key, $_ENV ?? [])) { + $value = $_ENV[$key]; + } + if ($value === false && array_key_exists($key, $_SERVER ?? [])) { + $value = $_SERVER[$key]; + } + } + if ($value === false || $value === null || is_array($value) || is_object($value)) { + return; + } + + $env[$key] = (string)$value; + } + + private function releaseRuntimeEnvKeyAllowed(string $key): bool + { + if (in_array($key, self::RELEASE_API_RUNTIME_ENV_KEYS, true)) { + return true; + } + + foreach (self::RELEASE_API_RUNTIME_ENV_PREFIXES as $prefix) { + if (str_starts_with($key, $prefix)) { + return true; + } + } + + return false; + } + + private function normalizeCoolifyRuntimeEnv(array $env): array + { + $normalized = []; + foreach ($env as $key => $value) { + $this->appendRuntimeEnvValue($normalized, (string)$key, $value); + } + ksort($normalized); + return $normalized; + } + + private function releaseCoolifyApplicationPayload(array $target, array $context, array $instance): array + { + $publicUrl = $this->releaseCoolifyPublicUrl($target, $context); + $projectUuid = trim((string)($context['coolify_project_uuid'] ?? $context['project_uuid'] ?? $instance['default_project_uuid'] ?? '')); + if ($projectUuid === '') { + throw new RuntimeException('Select a Coolify project for this release target before creating an application.'); + } + + $githubAppUuid = $this->releaseCoolifyGithubAppUuid($context, $target, $instance, true); + if ($githubAppUuid === '') { + throw new RuntimeException('Release Manager could not resolve a Coolify GitHub App UUID so Coolify can pull with the app token.'); + } + + $serverUuid = $this->releaseCoolifyServerUuid($context, $instance); + if ($serverUuid === '') { + throw new RuntimeException('Release Manager could not resolve a Coolify server UUID automatically for this instance.'); + } + + $repository = trim((string)($target['repository'] ?? '')); + if ($repository === '') { + throw new RuntimeException('Repository is required before creating a Coolify GitHub App application.'); + } + + $environment = $this->releaseCoolifyEnvironment($target, $context, $instance); + $payload = [ + 'name' => $this->releaseCoolifyResourceName($target, $context), + 'description' => 'Truckwash release manager target for ' . $repository, + 'project_uuid' => $projectUuid, + 'environment_name' => $environment['name'], + 'environment_uuid' => $environment['uuid'], + 'server_uuid' => $serverUuid, + 'destination_uuid' => trim((string)($context['coolify_destination_uuid'] ?? $context['destination_uuid'] ?? $instance['default_destination_uuid'] ?? '')), + 'github_app_uuid' => $githubAppUuid, + 'git_repository' => $repository, + 'git_branch' => trim((string)($target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH, + 'git_commit_sha' => $this->releaseCoolifyGitCommitSha($target, $context), + 'build_pack' => $this->releaseCoolifyBuildPack($target, $context), + 'ports_exposes' => $this->releaseCoolifyPortsExposes($target, $context), + 'instant_deploy' => $this->toBool($context['coolify_deploy_now'] ?? true), + 'is_auto_deploy_enabled' => $this->toBool($target['auto_deploy'] ?? true), + 'force_domain_override' => true, + ]; + + foreach ($this->releaseCoolifyApplicationDefaultFields($target, $context) as $key => $value) { + $payload[$key] = $value; + } + + if ($publicUrl !== null) { + $payload['domains'] = self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context)); + $payload['is_force_https_enabled'] = $this->toBool($context['coolify_enable_ssl'] ?? false); + } + + foreach ($this->releaseCoolifyApplicationOptionalFields($context) as $key => $value) { + $payload[$key] = $value; + } + + return array_filter($payload, static fn(mixed $value): bool => $value !== null && $value !== ''); + } + + private function releaseCoolifyApplicationRoutePayload( + array $target, + array $context, + string $publicUrl, + string $resourceUuid, + mixed $existingLabels = null + ): array + { + $decodedLabels = self::decodeCoolifyLabels($existingLabels); + $routePort = $this->releaseCoolifyProxyPort($target, $context) + ?? self::coolifyLabelFirstServicePort($decodedLabels, $resourceUuid); + $payload = [ + 'domains' => self::coolifyProxyUrl($publicUrl, $routePort), + 'is_force_https_enabled' => true, + 'force_domain_override' => true, + ]; + + $labels = self::releaseCoolifyApplicationLabels( + $publicUrl, + $resourceUuid, + $routePort, + self::gatewayRouteDefaultCertResolver($publicUrl) + ); + if ($labels !== []) { + $payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels( + $decodedLabels, + $labels + ))); + } + + return $payload; + } + + private function releaseCoolifyApplicationUpdatePayload(array $target, array $context): array + { + $app = strtolower(trim((string)($target['app'] ?? ''))); + $buildPack = $this->releaseCoolifyBuildPack($target, $context); + $payload = [ + 'git_repository' => trim((string)($target['repository'] ?? '')), + 'git_branch' => trim((string)($target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH, + 'git_commit_sha' => $this->releaseCoolifyGitCommitSha($target, $context), + 'build_pack' => $buildPack, + 'ports_exposes' => $this->releaseCoolifyPortsExposes($target, $context), + ]; + + foreach ($this->releaseCoolifyApplicationDefaultFields($target, $context) as $key => $value) { + $payload[$key] = $value; + } + + foreach ($this->releaseCoolifyApplicationOptionalFields($context) as $key => $value) { + $payload[$key] = $value; + } + + if ($app === 'frontend' && $buildPack === 'dockerfile') { + $payload['install_command'] = ''; + $payload['build_command'] = ''; + $payload['start_command'] = ''; + $payload['publish_directory'] = ''; + $payload['is_static'] = false; + $payload['is_spa'] = false; + } + + return array_filter( + $payload, + static fn(mixed $value, string $key): bool => $value !== null + && ($value !== '' || in_array($key, ['install_command', 'build_command', 'start_command', 'publish_directory'], true)), + ARRAY_FILTER_USE_BOTH + ); + } + + private static function releaseCoolifyApplicationLabels( + string $publicUrl, + string $resourceUuid, + ?int $port = null, + ?string $certResolver = null + ): array + { + $resourceUuid = self::coolifyRouteLabelId($resourceUuid); + if ($resourceUuid === '') { + return []; + } + + $parts = parse_url($publicUrl); + $host = trim((string)($parts['host'] ?? '')); + if ($host === '') { + return []; + } + + $scheme = strtolower((string)($parts['scheme'] ?? 'https')); + $path = trim((string)($parts['path'] ?? '/')); + $path = $path !== '' ? $path : '/'; + if ($path[0] !== '/') { + $path = '/' . $path; + } + + $routePort = $port ?? self::firstInteger($parts['port'] ?? null); + $certResolver = trim((string)($certResolver ?? '')); + $httpLabel = 'http-0-' . $resourceUuid; + $httpsLabel = 'https-0-' . $resourceUuid; + $priority = (string)(1000 + strlen($path)); + $labels = [ + 'traefik.enable=true', + 'traefik.http.middlewares.gzip.compress=true', + 'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https', + ]; + + if ($scheme === 'https') { + $labels[] = "traefik.http.routers.{$httpsLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"; + $labels[] = "traefik.http.routers.{$httpsLabel}.entryPoints=https"; + $labels[] = "traefik.http.routers.{$httpsLabel}.priority={$priority}"; + if ($routePort !== null) { + $labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}"; + $labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}"; + } + if ($path !== '/') { + $labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}"; + $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip"; + } else { + $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip"; + } + $labels[] = "traefik.http.routers.{$httpsLabel}.tls=true"; + if ($certResolver !== '') { + $labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}"; + } + $labels[] = "traefik.http.routers.{$httpsLabel}.tls.domains[0].main={$host}"; + $labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"; + $labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http"; + $labels[] = "traefik.http.routers.{$httpLabel}.priority={$priority}"; + if ($routePort !== null) { + $labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}"; + $labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}"; + } + $labels[] = "traefik.http.routers.{$httpLabel}.middlewares=redirect-to-https"; + } else { + $labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"; + $labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http"; + $labels[] = "traefik.http.routers.{$httpLabel}.priority={$priority}"; + if ($routePort !== null) { + $labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}"; + $labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}"; + } + if ($path !== '/') { + $labels[] = "traefik.http.middlewares.{$httpLabel}-stripprefix.stripprefix.prefixes={$path}"; + $labels[] = "traefik.http.routers.{$httpLabel}.middlewares={$httpLabel}-stripprefix,gzip"; + } else { + $labels[] = "traefik.http.routers.{$httpLabel}.middlewares=gzip"; + } + } + + sort($labels); + return $labels; + } + + private static function mergeCoolifyLabels(array $existingLabels, array $generatedLabels): array + { + $merged = []; + foreach (array_merge($existingLabels, $generatedLabels) as $label) { + $label = trim((string)$label); + if ($label === '') { + continue; + } + $merged[self::coolifyLabelKey($label)] = $label; + } + + return array_values($merged); + } + + private static function coolifyLabelFirstServicePort(array $labels, string $resourceUuid): ?int + { + $resourceUuid = self::coolifyRouteLabelId($resourceUuid); + $fallback = null; + foreach ($labels as $label) { + if (preg_match('/^traefik\.http\.services\.([^=]+)\.loadbalancer\.server\.port=(\d+)$/', trim((string)$label), $matches) !== 1) { + continue; + } + $port = (int)$matches[2]; + if ($port <= 0) { + continue; + } + if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) { + return $port; + } + $fallback ??= $port; + } + + return $fallback; + } + + private static function coolifyLabelCertResolver(array $labels, string $resourceUuid): ?string + { + $resourceUuid = self::coolifyRouteLabelId($resourceUuid); + $fallback = null; + foreach ($labels as $label) { + if (preg_match('/^traefik\.http\.routers\.([^=]+)\.tls\.certresolver=([A-Za-z0-9_.-]+)$/', trim((string)$label), $matches) !== 1) { + continue; + } + $resolver = trim((string)$matches[2]); + if ($resolver === '') { + continue; + } + if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) { + return $resolver; + } + $fallback ??= $resolver; + } + + return $fallback; + } + + private static function gatewayRouteDefaultCertResolver(string $publicUrl): string + { + return 'letsencrypt'; + } + + private static function decodeCoolifyLabels(mixed $labels): array + { + if (!is_scalar($labels)) { + return []; + } + + $raw = trim((string)$labels); + if ($raw === '') { + return []; + } + + $decoded = base64_decode($raw, true); + $content = $decoded !== false ? $decoded : $raw; + return array_values(array_filter( + preg_split('/\r\n|\r|\n/', (string)$content) ?: [], + static fn(string $label): bool => trim($label) !== '' + )); + } + + private static function coolifyLabelKey(string $label): string + { + $position = strpos($label, '='); + return $position === false ? trim($label) : trim(substr($label, 0, $position)); + } + + private static function coolifyRouteLabelId(string $value): string + { + $value = strtolower(trim($value)); + $value = preg_replace('/[^a-z0-9-]+/', '-', $value) ?: ''; + return trim($value, '-'); + } + + private static function firstInteger(mixed $value): ?int + { + if (is_int($value)) { + return $value > 0 ? $value : null; + } + if (is_float($value)) { + return $value > 0 ? (int)$value : null; + } + if (is_array($value)) { + foreach ($value as $item) { + $integer = self::firstInteger($item); + if ($integer !== null) { + return $integer; + } + } + return null; + } + if (!is_scalar($value)) { + return null; + } + if (preg_match('/\d+/', (string)$value, $matches) !== 1) { + return null; + } + + $integer = (int)$matches[0]; + return $integer > 0 ? $integer : null; + } + + private static function coolifyProxyUrl(string $publicUrl, ?int $port): string + { + if ($port === null || $port <= 0) { + return $publicUrl; + } + + $parts = parse_url($publicUrl); + if (!is_array($parts) || trim((string)($parts['host'] ?? '')) === '' || isset($parts['port'])) { + return $publicUrl; + } + + $scheme = trim((string)($parts['scheme'] ?? 'https')) ?: 'https'; + $host = trim((string)$parts['host']); + $path = (string)($parts['path'] ?? ''); + $query = isset($parts['query']) ? '?' . $parts['query'] : ''; + $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : ''; + return "{$scheme}://{$host}:{$port}{$path}{$query}{$fragment}"; + } + + private static function releaseCoolifyPublicUrlNeedsStripPrefixLabels(?string $publicUrl): bool + { + if ($publicUrl === null || trim($publicUrl) === '') { + return false; + } + + $parts = parse_url($publicUrl); + if (!is_array($parts)) { + return false; + } + + return trim((string)($parts['path'] ?? ''), '/') !== ''; + } + + private function releaseCoolifyServicePayload(array $target, array $context, array $instance): array + { + $publicUrl = $this->releaseCoolifyPublicUrl($target, $context); + $projectUuid = trim((string)($context['coolify_project_uuid'] ?? $context['project_uuid'] ?? $instance['default_project_uuid'] ?? '')); + if ($projectUuid === '') { + throw new RuntimeException('Select a Coolify project for this release target before creating a service.'); + } + $environment = $this->releaseCoolifyEnvironment($target, $context, $instance); + $serverUuid = $this->releaseCoolifyServerUuid($context, $instance); + if ($serverUuid === '') { + throw new RuntimeException('Release Manager could not resolve a Coolify server UUID automatically for this instance.'); + } + + $name = $this->releaseCoolifyResourceName($target, $context); + $compose = (string)($context['docker_compose_raw'] ?? ''); + if (trim($compose) === '') { + $image = $this->releaseCoolifyExplicitImage($context); + if ($image === '' && $this->toBool($context['coolify_assume_ghcr_image'] ?? $context['assume_ghcr_image'] ?? false)) { + $repository = strtolower(trim((string)($target['repository'] ?? ''))); + $branch = self::safeIdentifier((string)($target['branch'] ?? self::DEFAULT_BRANCH), 64); + if ($repository !== '') { + $image = 'ghcr.io/' . $repository . ':' . ($branch !== '' ? $branch : self::DEFAULT_BRANCH); + } + } + if ($image === '') { + throw new RuntimeException('Coolify service creation needs docker_compose_raw or an explicit image in deploy_context. Release Manager will not assume a GHCR image from repository and branch.'); + } + $compose = "services:\n app:\n image: " . $image . "\n restart: unless-stopped\n"; + } + + $payload = [ + 'name' => $name, + 'description' => 'Truckwash release manager target for ' . (string)($target['repository'] ?? ''), + 'project_uuid' => $projectUuid, + 'environment_name' => $environment['name'], + 'environment_uuid' => $environment['uuid'], + 'server_uuid' => $serverUuid, + 'instant_deploy' => $this->toBool($context['coolify_deploy_now'] ?? true), + 'docker_compose_raw' => base64_encode($compose), + 'force_domain_override' => true, + ]; + + if ($publicUrl !== null) { + $payload['urls'] = [ + [ + 'name' => (string)($target['app'] ?? 'release'), + 'url' => self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context)), + ], + ]; + } + + return array_filter($payload, static fn(mixed $value): bool => $value !== null && $value !== ''); + } + + private function releaseCoolifyResourceName(array $target, array $context): string + { + $requestedName = trim((string)($context['coolify_service_name'] ?? $context['service_name'] ?? $context['coolify_application_name'] ?? $context['application_name'] ?? '')); + return self::safeIdentifier( + $requestedName !== '' + ? $requestedName + : 'release-' . (string)($target['channel_slug'] ?? $target['channel_id'] ?? 'channel') . '-' . (string)($target['app'] ?? 'app'), + 64 + ); + } + + private function releaseCoolifyServiceSourceIsMissing(array $context): bool + { + return trim((string)($context['docker_compose_raw'] ?? '')) === '' + && $this->releaseCoolifyExplicitImage($context) === '' + && !$this->toBool($context['coolify_assume_ghcr_image'] ?? $context['assume_ghcr_image'] ?? false); + } + + private function releaseCoolifyGithubAppUuid(array $context, array $target = [], array $instance = [], bool $discover = false): string + { + foreach ([ + 'coolify_github_app_uuid', + 'github_app_uuid', + 'coolify_git_app_uuid', + 'git_app_uuid', + 'default_github_app_uuid', + 'default_coolify_github_app_uuid', + ] as $key) { + $value = trim((string)($context[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + + if (!$discover) { + return ''; + } + + return $this->releaseCoolifyDefaultGithubAppUuid($target, $context, $instance); + } + + private function releaseCoolifyDefaultGithubAppUuid(array $target, array $context, array $instance): string + { + foreach ([ + 'default_github_app_uuid', + 'default_coolify_github_app_uuid', + 'coolify_github_app_uuid', + 'github_app_uuid', + ] as $key) { + $value = trim((string)($instance[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + + foreach ([ + getenv('RELEASE_MANAGER_COOLIFY_GITHUB_APP_UUID') ?: ($_SERVER['RELEASE_MANAGER_COOLIFY_GITHUB_APP_UUID'] ?? null), + getenv('COOLIFY_GITHUB_APP_UUID') ?: ($_SERVER['COOLIFY_GITHUB_APP_UUID'] ?? null), + $this->moduleConfigValue('ReleaseManager', 'coolify_github_app_uuid', ''), + $this->moduleConfigValue('Coolify', 'github_app_uuid', ''), + ] as $value) { + $value = trim((string)$value); + if ($value !== '') { + return $value; + } + } + + $tokenSecret = trim((string)($instance['api_token_secret'] ?? '')); + if ($tokenSecret === '') { + return ''; + } + + try { + $token = replication_secret_box::decrypt($tokenSecret); + $apps = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listGithubApps(); + } catch (Throwable) { + return ''; + } + + $rows = array_values(array_filter($this->payloadRows($apps), static function (mixed $row): bool { + return is_array($row) && trim((string)($row['uuid'] ?? '')) !== ''; + })); + if (count($rows) === 1) { + return trim((string)$rows[0]['uuid']); + } + + $repository = self::normalizeGithubRepositoryName((string)($target['repository'] ?? '')); + $owner = strtolower(trim(strtok($repository, '/') ?: '')); + if ($owner === '') { + return ''; + } + + $matches = array_values(array_filter($rows, static function (array $row) use ($owner): bool { + foreach (['organization', 'name', 'custom_user', 'html_url'] as $key) { + $value = strtolower(trim((string)($row[$key] ?? ''))); + if ($value === '') { + continue; + } + if ($value === $owner || str_contains($value, '/' . $owner) || str_contains($value, $owner . '-')) { + return true; + } + } + return false; + })); + + return count($matches) === 1 ? trim((string)$matches[0]['uuid']) : ''; + } + + private function releaseCoolifyResourceType(array $context, string $serviceUuid = ''): string + { + $type = strtolower(trim((string)($context['coolify_resource_type'] ?? $context['resource_type'] ?? ''))); + if (in_array($type, ['application', 'app'], true)) { + return 'application'; + } + if ($type === '' && trim($serviceUuid) === '' && $this->releaseCoolifyGithubAppUuid($context) !== '') { + return 'application'; + } + + return 'service'; + } + + private function releaseTargetNeedsApplicationAutoCreate(array $target, array $context): bool + { + if (!in_array(strtolower(trim((string)($target['app'] ?? ''))), self::APPS, true)) { + return false; + } + if ($this->nullablePositiveInt($target['coolify_instance_id'] ?? null) === null) { + return false; + } + if (trim((string)($target['coolify_service_uuid'] ?? '')) !== '') { + return false; + } + if ($this->toBool($context['coolify_auto_create'] ?? false)) { + return false; + } + + $publicUrl = $this->releaseCoolifyPublicUrl($target, $context); + return self::releaseCoolifyPublicUrlNeedsStripPrefixLabels($publicUrl) + || $this->releaseCoolifyResourceType($context, '') === 'application'; + } + + private function releaseCoolifyBuildPack(array $target, array $context): string + { + $app = strtolower(trim((string)($target['app'] ?? ''))); + $buildPack = strtolower(trim((string)($context['coolify_build_pack'] ?? $context['build_pack'] ?? ''))); + if ($buildPack !== '') { + if ($app === 'frontend' && $buildPack === 'nixpacks') { + return 'dockerfile'; + } + return $buildPack; + } + + return in_array($app, ['api', 'frontend'], true) ? 'dockerfile' : 'static'; + } + + private function releaseCoolifyPortsExposes(array $target, array $context): string + { + foreach ([ + 'coolify_ports_exposes', + 'ports_exposes', + 'coolify_exposed_port', + 'exposed_port', + 'coolify_port', + 'port', + ] as $key) { + $value = trim((string)($context[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + + $app = strtolower(trim((string)($target['app'] ?? ''))); + $envKeys = $app === 'api' + ? ['RELEASE_MANAGER_API_PORTS_EXPOSES', 'RELEASE_API_PORTS_EXPOSES', 'API_PORTS_EXPOSES'] + : ['RELEASE_MANAGER_FRONTEND_PORTS_EXPOSES', 'RELEASE_FRONTEND_PORTS_EXPOSES', 'FRONTEND_PORTS_EXPOSES']; + foreach ($envKeys as $key) { + $value = trim((string)(getenv($key) ?: ($_SERVER[$key] ?? ''))); + if ($value !== '') { + return $value; + } + } + + return self::DEFAULT_COOLIFY_APPLICATION_PORT; + } + + private function releaseCoolifyProxyPort(array $target, array $context): ?int + { + foreach ([ + 'coolify_ports_exposes', + 'ports_exposes', + 'coolify_exposed_port', + 'exposed_port', + 'coolify_port', + 'port', + ] as $key) { + $port = self::firstInteger($context[$key] ?? null); + if ($port !== null) { + return $port; + } + } + + $app = strtolower(trim((string)($target['app'] ?? ''))); + if ($app !== 'api') { + return null; + } + + return self::firstInteger($this->releaseCoolifyPortsExposes($target, $context)); + } + + private function releaseCoolifyGitCommitSha(array $target, array $context): string + { + foreach ([ + 'coolify_git_commit_sha', + 'git_commit_sha', + 'commit_sha', + 'commit', + ] as $key) { + $value = trim((string)($context[$key] ?? $target[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + + return ''; + } + + private function releaseCoolifyForceRebuild(array $context): bool + { + if (array_key_exists('coolify_force_rebuild', $context) || array_key_exists('force_rebuild', $context)) { + return $this->toBool($context['coolify_force_rebuild'] ?? $context['force_rebuild'] ?? false); + } + + return true; + } + + private function releaseCoolifyApplicationDefaultFields(array $target, array $context): array + { + $app = strtolower(trim((string)($target['app'] ?? ''))); + $buildPack = $this->releaseCoolifyBuildPack($target, $context); + + if ($app === 'api' && $buildPack === 'dockerfile') { + return [ + 'dockerfile_location' => self::DEFAULT_COOLIFY_API_DOCKERFILE, + ]; + } + + if ($app === 'frontend' && $buildPack === 'dockerfile') { + return [ + 'dockerfile_location' => '/Dockerfile.coolify-frontend', + ]; + } + + if ($app !== 'frontend' || $buildPack !== 'static') { + return []; + } + + return [ + 'install_command' => 'npm ci', + 'build_command' => 'npm run build', + 'publish_directory' => 'dist', + 'is_static' => true, + 'is_spa' => true, + ]; + } + + private function releaseCoolifyApplicationOptionalFields(array $context): array + { + $fields = []; + foreach ([ + 'base_directory', + 'publish_directory', + 'dockerfile', + 'dockerfile_location', + 'docker_compose_location', + 'ports_exposes', + 'ports_mappings', + 'install_command', + 'build_command', + 'start_command', + ] as $key) { + $value = trim((string)($context['coolify_' . $key] ?? $context[$key] ?? '')); + if ($value !== '') { + $fields[$key] = $value; + } + } + + foreach ([ + 'is_static', + 'is_spa', + 'is_force_https_enabled', + 'is_auto_deploy_enabled', + ] as $key) { + if (array_key_exists('coolify_' . $key, $context) || array_key_exists($key, $context)) { + $fields[$key] = $this->toBool($context['coolify_' . $key] ?? $context[$key] ?? false); + } + } + + return $fields; + } + + private function releaseCoolifyEnvironment(array $target, array $context, array $instance): array + { + $explicitUuid = trim((string)($context['coolify_environment_uuid'] ?? $context['environment_uuid'] ?? '')); + $explicitName = trim((string)($context['coolify_environment_name'] ?? $context['environment_name'] ?? '')); + $releaseEnvironmentName = $this->releaseBranchCoolifyEnvironmentName($target); + if ($explicitUuid !== '' || ($explicitName !== '' && !($releaseEnvironmentName !== null && strtolower($explicitName) === 'production'))) { + return [ + 'uuid' => $explicitUuid !== '' ? $explicitUuid : null, + 'name' => $explicitName !== '' ? $explicitName : (trim((string)($instance['default_environment_name'] ?? 'production')) ?: 'production'), + ]; + } + + if ($releaseEnvironmentName !== null) { + return [ + 'uuid' => null, + 'name' => $releaseEnvironmentName, + ]; + } + + return [ + 'uuid' => trim((string)($instance['default_environment_uuid'] ?? '')) ?: null, + 'name' => trim((string)($instance['default_environment_name'] ?? 'production')) ?: 'production', + ]; + } + + private function releaseBranchCoolifyEnvironmentName(array $target): ?string + { + $channelSlug = self::safeSlug((string)($target['channel_slug'] ?? $target['channel'] ?? '')); + if ($channelSlug !== '' && in_array($channelSlug, self::DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS, true)) { + return null; + } + + $branchSlug = self::safeSlug(preg_replace('#^refs/heads/#', '', (string)($target['branch'] ?? '')) ?? ''); + if ($channelSlug === '' && $branchSlug === '') { + return null; + } + + if ($branchSlug !== '' && !in_array($branchSlug, ['main', 'master'], true)) { + return $branchSlug; + } + + return $channelSlug !== '' ? $channelSlug : null; + } + + private function releaseCoolifyExplicitImage(array $context): string + { + foreach (['image', 'docker_image', 'coolify_image', 'coolify_docker_image', 'registry_image'] as $key) { + $value = $context[$key] ?? null; + if (is_scalar($value)) { + $image = trim((string)$value); + if ($image !== '') { + return $image; + } + } + } + + return ''; + } + + private function releaseCoolifyServerUuid(array $context, array $instance): string + { + $explicit = trim((string)($context['coolify_server_uuid'] ?? $context['server_uuid'] ?? '')); + if ($explicit !== '') { + return $explicit; + } + + $default = trim((string)($instance['default_server_uuid'] ?? '')); + if ($default !== '') { + return $default; + } + + $tokenSecret = trim((string)($instance['api_token_secret'] ?? '')); + if ($tokenSecret === '') { + return ''; + } + + try { + $token = replication_secret_box::decrypt($tokenSecret); + $servers = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listServers(); + } catch (Throwable) { + return ''; + } + + $firstServerUuid = ''; + foreach ($this->payloadRows($servers) as $server) { + if (!is_array($server)) { + continue; + } + $uuid = trim((string)($server['uuid'] ?? '')); + if ($uuid === '') { + continue; + } + if ($firstServerUuid === '') { + $firstServerUuid = $uuid; + } + $settings = is_array($server['settings'] ?? null) ? $server['settings'] : []; + if (($settings['is_reachable'] ?? true) !== false && ($settings['is_usable'] ?? true) !== false) { + return $uuid; + } + } + + return $firstServerUuid; + } + + private function releaseDeploymentEndpoint(array $target): array + { + $context = is_array($target['deploy_context'] ?? null) + ? $target['deploy_context'] + : self::jsonDecode($target['deploy_context_json'] ?? null); + $mode = strtolower(trim((string)($context['endpoint_mode'] ?? 'auto'))) === 'manual' ? 'manual' : 'auto'; + $app = (string)($target['app'] ?? ''); + + if ($mode === 'manual') { + $host = self::normalizeEndpointHost($context['manual_endpoint_host'] ?? ''); + $port = $this->releaseEndpointPort($context['manual_endpoint_port'] ?? null); + if ($host !== '') { + return $this->releaseEndpointFromParts( + 'manual', + 'resolved', + $host, + $port, + 'manual', + 'Manual endpoint override is configured.' + ); + } + + return self::releasePendingEndpoint( + 'manual', + 'manual', + 'Manual endpoint mode needs a public host before deployment.' + ); + } + + foreach ([ + 'coolify_public_url' => 'coolify_public_url', + 'health_url' => 'health_url', + 'coolify_domain' => 'coolify_domain', + ] as $key => $source) { + $value = $key === 'health_url' + ? ($target['health_url'] ?? null) + : ($context[$key] ?? null); + $url = $key === 'coolify_domain' + ? $this->releaseRoutedPublicBaseUrl($value, $target, $context) + : ($key === 'coolify_public_url' + ? $this->releaseRoutedPublicBaseUrl($value, $target, $context) + : $this->normalizeReleasePublicBaseUrl($value, $app)); + if ($url !== null) { + return $this->releaseEndpointFromUrl( + $url, + 'auto', + 'resolved', + $source, + 'Automatic endpoint resolved from ' . str_replace('_', ' ', $source) . '.' + ); + } + } + + $gatewayUrl = $this->releaseAutoGatewayPublicBaseUrl($target, $context); + if ($gatewayUrl !== null) { + return $this->releaseEndpointFromUrl( + $gatewayUrl, + 'auto', + 'pending', + 'auto_gateway', + 'Automatic gateway endpoint will be used when Coolify routing is ready.' + ); + } + + foreach ([ + 'coolify_deployed_public_url', + 'deployed_public_url', + 'resource_public_url', + 'public_url', + 'coolify_deployed_url', + 'deployed_url', + ] as $key) { + $url = $this->normalizeReleasePublicBaseUrl($context[$key] ?? null, $app); + if ($url !== null) { + return $this->releaseEndpointFromUrl( + $url, + 'auto', + 'resolved', + 'coolify_resource_metadata', + 'Automatic endpoint resolved from deployed Coolify resource metadata.' + ); + } + } + + return self::releasePendingEndpoint( + 'auto', + 'auto', + 'Automatic endpoint resolution is pending deployment metadata.' + ); + } + + private static function releasePendingEndpoint(string $mode, string $source, string $message): array + { + return [ + 'mode' => $mode, + 'status' => 'pending', + 'host' => null, + 'port' => null, + 'url' => null, + 'source' => $source, + 'message' => $message, + ]; + } + + private function releaseEndpointFromUrl(string $url, string $mode, string $status, string $source, string $message): array + { + $normalized = $this->normalizeReleasePublicBaseUrl($url); + if ($normalized === null) { + return self::releasePendingEndpoint($mode, $source, $message); + } + + $parts = parse_url($normalized); + if (!is_array($parts) || empty($parts['host'])) { + return self::releasePendingEndpoint($mode, $source, $message); + } + + $scheme = strtolower((string)($parts['scheme'] ?? 'https')); + $port = isset($parts['port']) ? (int)$parts['port'] : ($scheme === 'http' ? 80 : 443); + return [ + 'mode' => $mode, + 'status' => $status, + 'host' => strtolower((string)$parts['host']), + 'port' => $port, + 'url' => $normalized, + 'source' => $source, + 'message' => $message, + ]; + } + + private function releaseEndpointFromParts( + string $mode, + string $status, + string $host, + ?int $port, + string $source, + string $message + ): array { + $host = self::normalizeEndpointHost($host); + if ($host === '') { + return self::releasePendingEndpoint($mode, $source, $message); + } + + $url = 'https://' . $host . ($port !== null && $port !== 443 ? ':' . $port : ''); + return [ + 'mode' => $mode, + 'status' => $status, + 'host' => strtolower($host), + 'port' => $port, + 'url' => $url, + 'source' => $source, + 'message' => $message, + ]; + } + + private function releaseEndpointPort(mixed $value): ?int + { + $raw = trim((string)$value); + if ($raw === '') { + return null; + } + if (filter_var($raw, FILTER_VALIDATE_INT) === false) { + return null; + } + $port = (int)$raw; + return $port >= 1 && $port <= 65535 ? $port : null; + } + + private static function normalizeEndpointHost(mixed $value): string + { + $raw = trim((string)$value); + if ($raw === '') { + return ''; + } + if (preg_match('#^https?://#i', $raw) === 1) { + $parts = parse_url($raw); + $raw = is_array($parts) ? (string)($parts['host'] ?? '') : ''; + } + $raw = trim($raw); + $raw = preg_replace('#[/\s].*$#', '', $raw) ?? ''; + if (str_contains($raw, ':') && preg_match('/^\[[^\]]+\]:(\d+)$/', $raw) !== 1) { + $parts = parse_url('https://' . $raw); + if (is_array($parts) && !empty($parts['host'])) { + $raw = (string)$parts['host']; + } + } + return strtolower(trim($raw, " \t\n\r\0\x0B[]")); + } + + private function releaseAutoGatewayPublicBaseUrl(array $target, array $context): ?string + { + $host = $this->releasePublicGatewayHost($context); + if ($host === '') { + return null; + } + + return $this->releaseRoutedPublicBaseUrl('https://' . $host, $target, array_replace($context, [ + 'gateway_route_autoprovision' => false, + ])); + } + + private function releasePublicGatewayHost(array $context = []): string + { + foreach ([ + $context['public_gateway_host'] ?? null, + $context['coolify_public_gateway_host'] ?? null, + ] as $value) { + $host = self::normalizeEndpointHost($value); + if ($host !== '') { + return $host; + } + } + + foreach ([ + getenv('RELEASE_MANAGER_PUBLIC_GATEWAY_HOST') ?: ($_SERVER['RELEASE_MANAGER_PUBLIC_GATEWAY_HOST'] ?? null), + getenv('COOLIFY_PUBLIC_GATEWAY_HOST') ?: ($_SERVER['COOLIFY_PUBLIC_GATEWAY_HOST'] ?? null), + ] as $value) { + $host = self::normalizeEndpointHost($value); + if ($host !== '') { + return $host; + } + } + + try { + if ($this->tableExists('module_config')) { + $host = self::normalizeEndpointHost($this->moduleConfigValue('Coolify', 'public_gateway_host', '')); + if ($host !== '') { + return $host; + } + } + } catch (Throwable) { + } + + return 'api-v2.truckwash.io'; + } + + private function releaseCoolifyPublicUrl(array $target, array $context): ?string + { + $endpoint = $this->releaseDeploymentEndpoint($target + ['deploy_context' => $context]); + if (!empty($endpoint['url']) && in_array((string)($endpoint['source'] ?? ''), [ + 'manual', + 'coolify_public_url', + 'health_url', + 'coolify_domain', + 'auto_gateway', + 'coolify_resource_metadata', + ], true)) { + return (string)$endpoint['url']; + } + + $explicitPublicUrl = $this->releaseRoutedPublicBaseUrl($context['coolify_public_url'] ?? null, $target, $context); + if ($explicitPublicUrl !== null) { + return $explicitPublicUrl; + } + + $raw = trim((string)($context['coolify_domain'] ?? '')); + $hasCoolifyDomain = $raw !== ''; + if ($raw === '') { + $raw = trim((string)($target['health_url'] ?? '')); + } + if ($raw === '') { + return null; + } + if ($this->toBool($context['coolify_enable_ssl'] ?? false)) { + $domain = self::domainSuggestionHost($raw); + if ($domain === null) { + throw new RuntimeException('Coolify SSL requires a DNS domain routed to the load balancer.'); + } + return $hasCoolifyDomain + ? $this->releaseRoutedPublicBaseUrl('https://' . $domain, $target, $context) + : $this->normalizeReleasePublicBaseUrl('https://' . $domain, (string)($target['app'] ?? '')); + } + if (preg_match('#^https?://#i', $raw) !== 1) { + $raw = 'http://' . $raw; + } + return $hasCoolifyDomain + ? $this->releaseRoutedPublicBaseUrl($raw, $target, $context) + : $this->normalizeReleasePublicBaseUrl($raw, (string)($target['app'] ?? '')); + } + + private function releaseRoutedPublicBaseUrl(mixed $value, array $target, array $context = []): ?string + { + $app = (string)($target['app'] ?? ''); + $baseUrl = $this->normalizeReleasePublicBaseUrl($value, $app); + if ($baseUrl === null) { + return null; + } + + $parts = parse_url($baseUrl); + if (!is_array($parts) || empty($parts['host'])) { + return $baseUrl; + } + + if ($this->toBool($context['gateway_route_autoprovision'] ?? false)) { + return $baseUrl; + } + + $path = trim((string)($parts['path'] ?? ''), '/'); + if ($path !== '') { + return $baseUrl; + } + + $channelSlug = self::safeSlug((string)( + $target['channel_slug'] + ?? $context['channel_slug'] + ?? $target['release_channel'] + ?? $context['release_channel'] + ?? $target['channel'] + ?? $context['channel'] + ?? '' + )); + $appSlug = self::safeSlug($app); + if ($channelSlug === '' || $appSlug === '') { + return $baseUrl; + } + $routeSlug = self::routeSlugForChannel($channelSlug); + + $scheme = strtolower((string)($parts['scheme'] ?? 'https')); + $host = strtolower((string)$parts['host']); + $port = isset($parts['port']) ? ':' . (int)$parts['port'] : ''; + + return sprintf('%s://%s%s/%s/%s', $scheme, $host, $port, $routeSlug, $appSlug); + } + + private function releaseTargetPublicBaseUrl(array $target): ?string + { + $context = is_array($target['deploy_context'] ?? null) + ? $target['deploy_context'] + : self::jsonDecode($target['deploy_context_json'] ?? null); + $app = (string)($target['app'] ?? ''); + $endpoint = is_array($target['endpoint'] ?? null) + ? $target['endpoint'] + : $this->releaseDeploymentEndpoint($target + ['deploy_context' => $context]); + return $this->normalizeReleasePublicBaseUrl($endpoint['url'] ?? null, $app) + ?? $this->releaseRoutedPublicBaseUrl($context['coolify_public_url'] ?? null, $target, $context) + ?? $this->normalizeReleasePublicBaseUrl($target['health_url'] ?? null, $app) + ?? $this->releaseRoutedPublicBaseUrl($context['coolify_domain'] ?? null, $target, $context) + ?? (is_array($target['endpoint'] ?? null) ? ($target['endpoint']['url'] ?? null) : null) + ?? ($this->releaseDeploymentEndpoint($target)['url'] ?? null); + } + + private function timelineSessionContext(array $context): array + { + $device = is_array($context['device'] ?? null) ? $context['device'] : []; + $browser = is_array($context['browser'] ?? null) ? $context['browser'] : []; + $os = is_array($context['os'] ?? null) ? $context['os'] : []; + $viewport = is_array($context['viewport'] ?? null) ? $context['viewport'] : []; + $frontend = is_array($context['frontend'] ?? null) ? $context['frontend'] : []; + $api = is_array($context['api'] ?? null) ? $context['api'] : []; + + return [ + 'device_type' => $this->nullableIdentifier($context['device_type'] ?? $device['type'] ?? null, 16), + 'browser_name' => $this->nullableString($context['browser_name'] ?? $browser['name'] ?? null, 64), + 'browser_version' => $this->nullableString($context['browser_version'] ?? $browser['version'] ?? null, 64), + 'os_name' => $this->nullableString($context['os_name'] ?? $os['name'] ?? null, 64), + 'os_version' => $this->nullableString($context['os_version'] ?? $os['version'] ?? null, 64), + 'viewport_width' => $this->nullableInt($context['viewport_width'] ?? $viewport['width'] ?? null), + 'viewport_height' => $this->nullableInt($context['viewport_height'] ?? $viewport['height'] ?? null), + 'device_pixel_ratio' => $this->nullableFloat($context['device_pixel_ratio'] ?? $viewport['device_pixel_ratio'] ?? null), + 'frontend_version_label' => $this->nullableString( + $context['frontend_version_label'] ?? $frontend['version_label'] ?? $context['frontend_version'] ?? null, + 128 + ), + 'frontend_commit_sha' => $this->nullableString( + $context['frontend_commit_sha'] ?? $frontend['commit_sha'] ?? $context['frontend_commit'] ?? null, + 128 + ), + 'api_version_label' => $this->nullableString( + $context['api_version_label'] ?? $api['version_label'] ?? $context['api_version'] ?? null, + 128 + ), + 'api_commit_sha' => $this->nullableString( + $context['api_commit_sha'] ?? $api['commit_sha'] ?? $context['backend_version'] ?? null, + 128 + ), + 'last_route_path' => $this->nullableString($context['route_path'] ?? $context['route'] ?? null, 255), + ]; + } + + private function latestRouteFromContext(array $context): ?string + { + foreach (['route_path', 'route'] as $key) { + $value = $this->nullableString($context[$key] ?? null, 255); + if ($value !== null) { + return $value; + } + } + return null; + } + + private function timelineSessionId(string $traceId, array $context, ?array $channel): int + { + $existing = $this->selectOne('SELECT id FROM release_timeline_sessions WHERE trace_id = ? LIMIT 1', 's', [$traceId]); + $userAgent = substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 512); + $principalType = trim((string)($context['principal_type'] ?? '')) ?: null; + $principalId = trim((string)($context['principal_id'] ?? '')) ?: null; + $customerNumber = isset($context['customer_number']) && is_numeric($context['customer_number']) + ? (int)$context['customer_number'] + : null; + $sessionContext = $this->timelineSessionContext($context); + $lastRoutePath = $sessionContext['last_route_path'] ?? null; + if ($lastRoutePath === null) { + $lastRoutePath = $this->latestRouteFromContext($context); + } + + if ($existing !== null) { + $this->execute( + "UPDATE release_timeline_sessions + SET last_seen_at = NOW(), channel_id = COALESCE(?, channel_id), channel_slug = COALESCE(?, channel_slug), + principal_type = COALESCE(?, principal_type), principal_id = COALESCE(?, principal_id), + customer_number = COALESCE(?, customer_number), + device_type = COALESCE(?, device_type), browser_name = COALESCE(?, browser_name), + browser_version = COALESCE(?, browser_version), os_name = COALESCE(?, os_name), + os_version = COALESCE(?, os_version), viewport_width = COALESCE(?, viewport_width), + viewport_height = COALESCE(?, viewport_height), device_pixel_ratio = COALESCE(?, device_pixel_ratio), + frontend_version_label = COALESCE(?, frontend_version_label), + frontend_commit_sha = COALESCE(?, frontend_commit_sha), + api_version_label = COALESCE(?, api_version_label), + api_commit_sha = COALESCE(?, api_commit_sha), + last_route_path = COALESCE(?, last_route_path), + user_agent = COALESCE(?, user_agent) + WHERE id = ?", + 'isssisssssiidssssssi', + [ + $channel['id'] ?? null, + $channel['slug'] ?? null, + $principalType, + $principalId, + $customerNumber, + $sessionContext['device_type'], + $sessionContext['browser_name'], + $sessionContext['browser_version'], + $sessionContext['os_name'], + $sessionContext['os_version'], + $sessionContext['viewport_width'], + $sessionContext['viewport_height'], + $sessionContext['device_pixel_ratio'], + $sessionContext['frontend_version_label'], + $sessionContext['frontend_commit_sha'], + $sessionContext['api_version_label'], + $sessionContext['api_commit_sha'], + $lastRoutePath, + $userAgent !== '' ? $userAgent : null, + (int)$existing['id'], + ] + ); + return (int)$existing['id']; + } + + $this->execute( + "INSERT INTO release_timeline_sessions ( + trace_id, session_hash, principal_type, principal_id, customer_number, + channel_id, channel_slug, device_type, browser_name, browser_version, os_name, + os_version, viewport_width, viewport_height, device_pixel_ratio, + frontend_version_label, frontend_commit_sha, api_version_label, api_commit_sha, + last_route_path, user_agent + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + 'ssssiisssssiidsssssss', + [ + $traceId, + $this->sessionHash(), + $principalType, + $principalId, + $customerNumber, + $channel['id'] ?? null, + $channel['slug'] ?? null, + $sessionContext['device_type'], + $sessionContext['browser_name'], + $sessionContext['browser_version'], + $sessionContext['os_name'], + $sessionContext['os_version'], + $sessionContext['viewport_width'], + $sessionContext['viewport_height'], + $sessionContext['device_pixel_ratio'], + $sessionContext['frontend_version_label'], + $sessionContext['frontend_commit_sha'], + $sessionContext['api_version_label'], + $sessionContext['api_commit_sha'], + $lastRoutePath, + $userAgent !== '' ? $userAgent : null, + ] + ); + + return $this->insertId(); + } + + private function sessionHash(): ?string + { + $authorization = (string)($_SERVER['HTTP_AUTHORIZATION'] ?? ''); + if ($authorization === '') { + return null; + } + return hash('sha256', str_replace('Bearer ', '', $authorization)); + } + + private function inferModuleKeyFromUri(string $uri): ?string + { + $path = strtolower(explode('?', $uri)[0] ?? ''); + $map = [ + '/auth' => 'auth', + '/superuser/releases' => 'releasemanager', + '/release' => 'releasemanager', + '/superuser/coolify' => 'coolify', + '/coolify' => 'coolify', + '/failover' => 'failover', + '/edge-gateway' => 'edgegateway', + '/edgegateway' => 'edgegateway', + '/modules/action-logs' => 'moduleactionlogs', + '/worker' => 'worker', + '/economic' => 'economic', + '/stripe' => 'stripe', + '/selfserve' => 'selfserve', + '/bird' => 'bird', + '/xlvask' => 'xlvask', + ]; + + foreach ($map as $prefix => $moduleKey) { + if (str_starts_with($path, $prefix)) { + return $moduleKey; + } + } + + return null; + } + + private function timelineSummary(): array + { + $events = $this->selectOne( + "SELECT COUNT(*) AS total, + SUM(CASE WHEN severity = 'error' THEN 1 ELSE 0 END) AS errors, + MAX(created_at) AS last_event_at + FROM release_timeline_events" + ) ?? []; + $sessions = $this->selectOne('SELECT COUNT(*) AS total FROM release_timeline_sessions') ?? []; + + return [ + 'sessions' => (int)($sessions['total'] ?? 0), + 'events' => (int)($events['total'] ?? 0), + 'errors' => (int)($events['errors'] ?? 0), + 'last_event_at' => $events['last_event_at'] ?? null, + ]; + } + + private function latestModuleHealth(): array + { + return $this->selectRows( + "SELECT h.* + FROM release_module_health_snapshots h + INNER JOIN ( + SELECT module_key, MAX(checked_at) AS checked_at + FROM release_module_health_snapshots + GROUP BY module_key + ) latest ON latest.module_key = h.module_key AND latest.checked_at = h.checked_at + ORDER BY h.module_key" + ); + } + + private static function releaseBranchForChannel(array|string $channel): string + { + $slug = is_array($channel) ? (string)($channel['slug'] ?? '') : $channel; + $routeSlug = self::routeSlugForChannel($slug); + return $routeSlug !== '' ? $routeSlug : self::DEFAULT_BRANCH; + } + + private static function defaultRepositoryForApp(string $app): string + { + return match (strtolower(trim($app))) { + 'frontend' => 'copenhagentruckwash/pleno-vue', + 'api' => 'copenhagentruckwash/api', + default => '', + }; + } + + private function getOperationRun(int $id): array + { + $row = $this->selectOne( + "SELECT r.*, c.slug AS channel_slug, c.name AS channel_name, + (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id) AS step_count, + (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('passed', 'deployed')) AS passed_step_count, + (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status = 'failed') AS failed_step_count, + (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('warning', 'skipped')) AS warning_step_count + FROM release_operation_runs r + LEFT JOIN release_channels c ON c.id = r.channel_id + WHERE r.id = ? + LIMIT 1", + 'i', + [$id] + ); + if ($row === null) { + throw new RuntimeException('Release operation not found.'); + } + return $row; + } + + private function createOperationRun(string $operationType, array $input): int + { + $operationType = self::safeIdentifier($operationType, 64) ?: 'operation'; + $subjectType = self::safeIdentifier((string)($input['subject_type'] ?? ''), 64) ?: null; + $subjectId = trim((string)($input['subject_id'] ?? '')) ?: null; + $channelId = $this->nullablePositiveInt($input['channel_id'] ?? null); + $app = trim((string)($input['app'] ?? '')) !== '' ? $this->normalizeApp((string)$input['app']) : null; + $title = substr(trim((string)($input['title'] ?? $operationType)), 0, 255); + $context = is_array($input['context'] ?? null) ? $input['context'] : []; + $actorUserId = $this->nullablePositiveInt($input['actor_user_id'] ?? null); + + $this->execute( + "INSERT INTO release_operation_runs ( + operation_type, subject_type, subject_id, channel_id, app, status, + title, actor_user_id, context_json, started_at + ) VALUES (?, ?, ?, ?, ?, 'running', ?, ?, ?, NOW())", + 'sssissis', + [ + $operationType, + $subjectType, + $subjectId, + $channelId, + $app, + $title, + $actorUserId, + self::jsonEncode(self::redactPayload($context)), + ] + ); + + return $this->insertId(); + } + + private function recordOperationStep( + int $operationId, + string $stepKey, + string $label, + string $status, + ?string $message = null, + ?string $diagnostic = null, + ?string $solutionHint = null, + array $context = [] + ): void { + $stepKey = self::safeIdentifier($stepKey, 64) ?: 'step'; + $status = self::safeIdentifier($status, 32) ?: 'queued'; + $completedSql = in_array($status, ['passed', 'deployed', 'failed', 'warning', 'skipped'], true) ? 'NOW()' : 'NULL'; + $this->execute( + "INSERT INTO release_operation_steps ( + operation_run_id, step_key, label, status, message, diagnostic, + solution_hint, context_json, started_at, completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), $completedSql)", + 'isssssss', + [ + $operationId, + $stepKey, + substr($label, 0, 255), + $status, + $message, + $diagnostic, + $solutionHint, + self::jsonEncode(self::redactPayload($context)), + ] + ); + } + + private function completeOperationRun(int $operationId, string $status, string $summary, ?string $solutionHint = null): void + { + $status = self::safeIdentifier($status, 32) ?: 'completed'; + $this->execute( + "UPDATE release_operation_runs + SET status = ?, summary = ?, solution_hint = ?, completed_at = NOW() + WHERE id = ?", + 'sssi', + [$status, $summary, $solutionHint, $operationId] + ); + } + + private function publicOperationRun(array $operation, bool $includeSteps = true): array + { + $operationId = (int)($operation['id'] ?? 0); + $steps = []; + if ($includeSteps && $operationId > 0) { + $steps = array_map( + fn(array $row): array => $this->publicOperationStep($row), + $this->selectRows( + 'SELECT * FROM release_operation_steps WHERE operation_run_id = ? ORDER BY id ASC', + 'i', + [$operationId] + ) + ); + } + + return [ + 'id' => $operationId, + 'operation_type' => (string)($operation['operation_type'] ?? ''), + 'subject_type' => $operation['subject_type'] ?? null, + 'subject_id' => $operation['subject_id'] ?? null, + 'channel_id' => isset($operation['channel_id']) ? (int)$operation['channel_id'] : null, + 'channel_slug' => $operation['channel_slug'] ?? null, + 'channel_name' => $operation['channel_name'] ?? null, + 'app' => $operation['app'] ?? null, + 'status' => (string)($operation['status'] ?? 'unknown'), + 'title' => $operation['title'] ?? null, + 'summary' => $operation['summary'] ?? null, + 'solution_hint' => $operation['solution_hint'] ?? null, + 'context' => self::jsonDecode($operation['context_json'] ?? null), + 'step_count' => (int)($operation['step_count'] ?? count($steps)), + 'passed_step_count' => (int)($operation['passed_step_count'] ?? 0), + 'failed_step_count' => (int)($operation['failed_step_count'] ?? 0), + 'warning_step_count' => (int)($operation['warning_step_count'] ?? 0), + 'steps' => $includeSteps ? $steps : null, + 'actor_user_id' => isset($operation['actor_user_id']) ? (int)$operation['actor_user_id'] : null, + 'started_at' => $operation['started_at'] ?? null, + 'completed_at' => $operation['completed_at'] ?? null, + 'created_at' => $operation['created_at'] ?? null, + 'updated_at' => $operation['updated_at'] ?? null, + ]; + } + + private function publicOperationStep(array $step): array + { + return [ + 'id' => (int)($step['id'] ?? 0), + 'operation_run_id' => (int)($step['operation_run_id'] ?? 0), + 'step_key' => (string)($step['step_key'] ?? ''), + 'label' => (string)($step['label'] ?? ''), + 'status' => (string)($step['status'] ?? 'unknown'), + 'message' => $step['message'] ?? null, + 'diagnostic' => $step['diagnostic'] ?? null, + 'solution_hint' => $step['solution_hint'] ?? null, + 'context' => self::jsonDecode($step['context_json'] ?? null), + 'started_at' => $step['started_at'] ?? null, + 'completed_at' => $step['completed_at'] ?? null, + 'created_at' => $step['created_at'] ?? null, + 'updated_at' => $step['updated_at'] ?? null, + ]; + } + + private function activeDeploymentKey(int $channelId, string $app): string + { + return $channelId . ':' . $this->normalizeApp($app); + } + + private function activateDeploymentForChannelApp(int $deploymentId, int $channelId, string $app): void + { + $app = $this->normalizeApp($app); + $key = $this->activeDeploymentKey($channelId, $app); + $this->execute( + "UPDATE release_deployments + SET status = 'superseded', active_channel_app_key = NULL + WHERE active_channel_app_key = ? AND id <> ?", + 'si', + [$key, $deploymentId] + ); + $this->execute( + "UPDATE release_deployments + SET status = 'superseded', active_channel_app_key = NULL + WHERE channel_id = ? AND app = ? AND id <> ? AND status = 'active'", + 'isi', + [$channelId, $app, $deploymentId] + ); + $this->execute( + "UPDATE release_deployments + SET status = 'active', active_channel_app_key = ?, completed_at = COALESCE(completed_at, NOW()) + WHERE id = ?", + 'si', + [$key, $deploymentId] + ); + } + + private function currentDeploymentForChannelApp(int $channelId, string $app): ?array + { + $app = $this->normalizeApp($app); + $key = $this->activeDeploymentKey($channelId, $app); + $row = $this->selectOne( + "SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url + FROM release_deployments d + INNER JOIN release_channels c ON c.id = d.channel_id + LEFT JOIN release_versions v ON v.id = d.version_id + WHERE d.active_channel_app_key = ? + LIMIT 1", + 's', + [$key] + ); + if ($row !== null) { + return $row; + } + + return $this->selectOne( + "SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url + FROM release_deployments d + INNER JOIN release_channels c ON c.id = d.channel_id + LEFT JOIN release_versions v ON v.id = d.version_id + WHERE d.channel_id = ? AND d.app = ? AND d.status = 'active' + ORDER BY d.completed_at DESC, d.id DESC + LIMIT 1", + 'is', + [$channelId, $app] + ); + } + + private function deploymentTargetForChannelApp(int $channelId, string $app): ?array + { + return $this->selectOne( + "SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, i.label AS coolify_instance_label + FROM release_deployment_targets t + INNER JOIN release_channels c ON c.id = t.channel_id + LEFT JOIN coolify_instances i ON i.id = t.coolify_instance_id + WHERE t.deleted_at IS NULL AND t.channel_id = ? AND t.app = ? + ORDER BY t.auto_deploy DESC, t.id DESC + LIMIT 1", + 'is', + [$channelId, $this->normalizeApp($app)] + ); + } + + private function channelCurrentDeployments(int $channelId): array + { + $deployments = []; + foreach (self::APPS as $app) { + $row = $this->currentDeploymentForChannelApp($channelId, $app); + $deployments[$app] = $row !== null ? $this->publicDeployment($row) : null; + } + return $deployments; + } + + private function channelBranchStatus(array $channel): array + { + $channelId = (int)($channel['id'] ?? 0); + if ($this->channelUsesProductionServices($channel)) { + $serviceChannel = $this->productionServiceChannel(); + $serviceChannelId = (int)($serviceChannel['id'] ?? 0); + $branch = self::releaseBranchForChannel($serviceChannel); + $status = []; + foreach (self::APPS as $app) { + $target = $this->deploymentTargetForChannelApp($serviceChannelId, $app); + $repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app))); + $status[$app] = [ + 'repository' => $repository, + 'branch' => $branch, + 'route_slug' => self::routeSlugForChannel((string)($channel['slug'] ?? '')), + 'target_configured' => true, + 'target_branch' => $target['branch'] ?? $branch, + 'state' => self::PRODUCTION_SERVICE_POLICY, + 'retry_action' => null, + 'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''), + ]; + } + return $status; + } + + $branch = self::releaseBranchForChannel($channel); + $status = []; + foreach (self::APPS as $app) { + $target = $this->deploymentTargetForChannelApp($channelId, $app); + $repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app))); + $status[$app] = [ + 'repository' => $repository, + 'branch' => $branch, + 'route_slug' => self::routeSlugForChannel((string)($channel['slug'] ?? '')), + 'target_configured' => $target !== null, + 'target_branch' => $target['branch'] ?? null, + 'state' => $target !== null ? 'ready_to_check' : 'missing_target', + 'retry_action' => $target !== null ? 'sync_channel' : 'configure_target', + ]; + } + return $status; + } + + private function releaseDataServicesSummary(): array + { + $summary = []; + foreach ($this->selectRows("SELECT * FROM release_channels WHERE deleted_at IS NULL ORDER BY default_channel DESC, slug") as $channel) { + $summary[(string)$channel['slug']] = $this->channelDataServicesSummary($channel); + } + return $summary; + } + + private function channelDataServicesSummary(array $channel): array + { + $serviceChannel = $this->channelUsesProductionServices($channel) + ? $this->productionServiceChannel() + : $channel; + $serviceSet = $this->activeServiceSetForChannel((int)($serviceChannel['id'] ?? 0)); + $serviceSetMode = $serviceSet !== null ? (string)($serviceSet['mode'] ?? 'attach_existing') : self::PRODUCTION_DATA_POLICY; + $mode = $this->serviceSetDataPolicy($serviceSet); + $policy = $this->replicationPolicyForMode($mode); + $services = []; + foreach (self::STACK_DATA_KINDS as $kind) { + $target = $serviceSet !== null + ? $this->nullableCoolifyTarget($this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null)) + : null; + $replication = is_array($target['replication'] ?? null) ? $target['replication'] : []; + $lastStatus = is_array($replication['last_status'] ?? null) ? $replication['last_status'] : []; + $services[$kind] = [ + 'kind' => $kind, + 'mode' => $mode, + 'service_set_mode' => $serviceSetMode, + 'data_policy' => $mode, + 'state' => $target !== null ? (string)($target['availability_state'] ?? 'configured') : 'production_shared', + 'target' => $target, + 'replication_policy' => $policy, + 'default_shared_production' => $mode === self::PRODUCTION_DATA_POLICY, + 'change_requires_explicit_action' => true, + 'entity_facts' => [ + 'service' => $kind, + 'mode' => $mode, + 'service_set_mode' => $serviceSetMode, + 'data_policy' => $mode, + 'node' => $target['instance_label'] ?? null, + 'online_state' => $target['deployment_status'] ?? $target['availability_state'] ?? 'production_shared', + 'hostname' => $replication['host'] ?? null, + 'port' => $replication['port'] ?? null, + 'uptime' => $lastStatus['uptime'] ?? $lastStatus['uptime_text'] ?? null, + 'version' => $lastStatus['version'] ?? null, + 'replication_role' => $replication['role'] ?? null, + 'replication_lag' => $lastStatus['lag'] ?? $lastStatus['lag_seconds'] ?? null, + 'last_check' => $replication['last_checked_at'] ?? $target['last_reconciled_at'] ?? null, + ], + ]; + } + + return [ + 'channel_id' => (int)($channel['id'] ?? 0), + 'channel_slug' => (string)($channel['slug'] ?? ''), + 'service_channel_id' => (int)($serviceChannel['id'] ?? 0), + 'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''), + 'mode' => $serviceSet === null ? 'production_shared' : $mode, + 'service_set_mode' => $serviceSetMode, + 'data_policy' => $mode, + 'data_service_mode' => $mode, + 'policy' => $policy, + 'services' => $services, + ]; + } + + private function releaseReplicationPolicySummary(): array + { + return [ + 'default_mode' => 'production_shared', + 'normal_sync_changes_data_services' => false, + 'allowed_modes' => [ + 'production_shared', + 'attach_existing', + 'clone_existing', + 'fresh_empty', + 'isolated_stack', + ], + 'service_kinds' => self::STACK_DATA_KINDS, + 'change_control' => 'Data service mode changes are only allowed through explicit replication/failover actions.', + ]; + } + + private function replicationPolicyForMode(string $mode): array + { + return [ + 'mode' => $mode, + 'production_shared' => in_array($mode, ['production_shared', 'attach_existing'], true), + 'replication_configurable' => true, + 'failover_configurable' => true, + 'normal_sync_changes_service' => false, + ]; + } + + private function activeServiceSetForChannel(int $channelId): ?array + { + return $this->selectOne( + "SELECT s.*, c.slug AS channel_slug, c.name AS channel_name + FROM release_channel_versions v + INNER JOIN release_service_sets s ON s.id = v.service_set_id + LEFT JOIN release_channels c ON c.id = s.channel_id + WHERE v.channel_id = ? AND v.active = 1 AND s.deleted_at IS NULL + ORDER BY v.activated_at DESC, v.id DESC + LIMIT 1", + 'i', + [$channelId] + ); + } + + private function releaseCoolifySummary(): array + { + $targetCount = (int)($this->selectOne('SELECT COUNT(*) AS total FROM release_deployment_targets WHERE deleted_at IS NULL')['total'] ?? 0); + $instanceCount = $this->tableExists('coolify_instances') + ? (int)($this->selectOne('SELECT COUNT(*) AS total FROM coolify_instances WHERE deleted_at IS NULL')['total'] ?? 0) + : 0; + return [ + 'integrated' => $this->tableExists('coolify_instances'), + 'panel' => 'release_manager', + 'instances' => $instanceCount, + 'deployment_targets' => $targetCount, + 'legacy_route' => '/superuser/configuration/coolify', + 'redirect_panel' => '/superuser/configuration/releases/integrations?panel=coolify', + 'entity_facts' => [ + 'instances' => $instanceCount, + 'deployment_targets' => $targetCount, + 'status' => $this->tableExists('coolify_instances') ? 'integrated' : 'not_configured', + 'last_check' => date('c'), + ], + ]; + } + + private function releaseFailoverSummary(): array + { + $replicationHosts = $this->tableExists('replication_hosts') + ? (int)($this->selectOne('SELECT COUNT(*) AS total FROM replication_hosts')['total'] ?? 0) + : 0; + return [ + 'integrated' => $this->tableExists('replication_hosts'), + 'panel' => 'release_manager', + 'replication_hosts' => $replicationHosts, + 'default_data_mode' => 'production_shared', + 'normal_sync_triggers_failover' => false, + 'legacy_route' => '/superuser/configuration/failover', + 'redirect_panel' => '/superuser/configuration/releases/data-services?panel=failover', + 'entity_facts' => [ + 'replication_hosts' => $replicationHosts, + 'default_data_mode' => 'production_shared', + 'readiness' => $this->tableExists('replication_hosts') ? 'ready' : 'not_configured', + 'normal_sync_triggers_failover' => false, + 'last_check' => date('c'), + ], + ]; + } + + private function githubRepositoryAccess(array $input): array + { + $tokenConfigured = $this->hasGithubApiToken(); + $repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? '')); + $branch = trim((string)($input['branch'] ?? '')); + $rawCommitSha = trim((string)($input['commit_sha'] ?? $input['commit'] ?? '')); + $commitMode = $this->normalizeCommitMode((string)($input['commit_mode'] ?? ''), $rawCommitSha); + + if ($repository === '') { + return [ + 'ok' => false, + 'status' => 'invalid_repository', + 'token_configured' => $tokenConfigured, + 'message' => 'GitHub repository must use owner/repo format.', + 'repository' => trim((string)($input['repository'] ?? '')), + 'branch' => $branch, + 'commit_mode' => $commitMode, + ]; + } + if ($commitMode === 'specific' && $rawCommitSha === '') { + return [ + 'ok' => false, + 'status' => 'commit_required', + 'token_configured' => $tokenConfigured, + 'message' => 'Specific commit deployment requires a commit SHA.', + 'repository' => $repository, + 'branch' => $branch, + 'commit_mode' => $commitMode, + ]; + } + + if (!$tokenConfigured) { + $response = $this->githubTokenMissingResponse($repository, $branch); + $response['commit_mode'] = $commitMode; + return $response; + } + + try { + $repo = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository)); + $defaultBranch = trim((string)($repo['default_branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH; + $branch = $branch !== '' ? $branch : $defaultBranch; + $branchRow = $this->githubRequest( + 'GET', + '/repos/' . $this->githubRepositoryPath($repository) . '/branches/' . rawurlencode($branch) + ); + $latestCommitSha = trim((string)($branchRow['commit']['sha'] ?? '')); + $commitSha = $latestCommitSha; + $commitUrl = (string)($branchRow['commit']['url'] ?? ''); + $latestCommit = []; + if ($latestCommitSha !== '') { + $latestCommitRow = $this->githubRequest( + 'GET', + '/repos/' . $this->githubRepositoryPath($repository) . '/commits/' . rawurlencode($latestCommitSha) + ); + $latestCommit = is_array($latestCommitRow) ? $this->publicGithubCommit($latestCommitRow) : []; + $commitUrl = (string)($latestCommit['html_url'] ?? $commitUrl); + } + $commit = $latestCommit; + if ($commitMode === 'specific') { + $commitRow = $this->githubRequest( + 'GET', + '/repos/' . $this->githubRepositoryPath($repository) . '/commits/' . rawurlencode($rawCommitSha) + ); + $commit = is_array($commitRow) ? $this->publicGithubCommit($commitRow) : []; + $commitSha = trim((string)($commit['sha'] ?? (is_array($commitRow) ? ($commitRow['sha'] ?? null) : null) ?? $rawCommitSha)); + $commitUrl = (string)($commit['html_url'] ?? (is_array($commitRow) ? ($commitRow['html_url'] ?? null) : null) ?? $commitUrl); + if ($latestCommitSha !== '' && $commitSha !== '' && $commitSha !== $latestCommitSha) { + $comparison = $this->githubRequest( + 'GET', + '/repos/' . $this->githubRepositoryPath($repository) . '/compare/' . rawurlencode($commitSha) . '...' . rawurlencode($latestCommitSha) + ); + $comparisonStatus = (string)($comparison['status'] ?? ''); + if (!in_array($comparisonStatus, ['behind', 'identical'], true)) { + throw new RuntimeException(sprintf('Commit %s is not reachable from branch %s.', $commitSha, $branch)); + } + } + } + + return [ + 'ok' => true, + 'status' => 'accessible', + 'token_configured' => true, + 'message' => $commitMode === 'specific' + ? 'Repository, branch, and commit are accessible with the configured GitHub token.' + : 'Repository and branch are accessible with the configured GitHub token.', + 'repository' => $repository, + 'branch' => $branch, + 'default_branch' => $defaultBranch, + 'private' => (bool)($repo['private'] ?? false), + 'html_url' => $repo['html_url'] ?? null, + 'commit_mode' => $commitMode, + 'commit_sha' => $commitSha !== '' ? $commitSha : null, + 'latest_commit_sha' => $latestCommitSha !== '' ? $latestCommitSha : null, + 'commit' => $commit !== [] ? $commit : null, + 'latest_commit' => $latestCommit !== [] ? $latestCommit : null, + 'commit_authored_at' => $commit['authored_at'] ?? null, + 'commit_url' => $commitUrl !== '' ? $commitUrl : null, + ]; + } catch (Throwable $throwable) { + return [ + 'ok' => false, + 'status' => 'inaccessible', + 'token_configured' => true, + 'message' => $throwable->getMessage(), + 'repository' => $repository, + 'branch' => $branch, + 'commit_mode' => $commitMode, + ]; + } + } + + private function normalizeCommitMode(string $commitMode, string $commitSha): string + { + $mode = strtolower(trim($commitMode)); + $commit = strtolower(trim($commitSha)); + if ($mode === 'specific') { + return 'specific'; + } + if ($mode === 'latest' || $mode === 'head' || $commit === '' || in_array($commit, ['latest', 'head'], true)) { + return 'latest'; + } + return 'specific'; + } + + private function githubTokenMissingResponse(?string $repository = null, ?string $branch = null): array + { + return [ + 'ok' => false, + 'status' => 'not_configured', + 'token_configured' => false, + 'message' => 'Configure ReleaseManager github_token or RELEASE_MANAGER_GITHUB_TOKEN before using private GitHub repositories.', + 'repository' => $repository, + 'branch' => $branch, + 'repositories' => [], + 'branches' => [], + 'commits' => [], + ]; + } + + private function hasGithubApiToken(): bool + { + return $this->githubApiToken() !== ''; + } + + private function githubEnvToken(): string + { + foreach (['RELEASE_MANAGER_GITHUB_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN'] as $key) { + $value = trim((string)(getenv($key) ?: ($_SERVER[$key] ?? ''))); + if ($value !== '') { + return $value; + } + } + + return ''; + } + + private function githubApiToken(): string + { + $envToken = $this->githubEnvToken(); + if ($envToken !== '') { + return $envToken; + } + + $token = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_token', '')); + if ($token !== '' && str_starts_with($token, 'twsec:v1:') && class_exists(replication_secret_box::class)) { + try { + $token = replication_secret_box::decrypt($token); + } catch (Throwable) { + $token = ''; + } + } + return trim($token); + } + + private function githubApiBaseUrl(): string + { + $value = trim((string)(getenv('RELEASE_MANAGER_GITHUB_API_URL') ?: ($_SERVER['RELEASE_MANAGER_GITHUB_API_URL'] ?? ''))); + if ($value === '') { + $value = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_api_url', 'https://api.github.com')); + } + $value = rtrim($value, '/'); + return preg_match('#^https?://#i', $value) === 1 ? $value : 'https://api.github.com'; + } + + private function githubRepositoryPath(string $repository): string + { + [$owner, $name] = explode('/', $repository, 2); + return rawurlencode($owner) . '/' . rawurlencode($name); + } + + private function githubRequest(string $method, string $path, array $query = []): array + { + $token = $this->githubApiToken(); + if ($token === '') { + throw new RuntimeException('GitHub token is not configured.'); + } + + $url = $this->githubApiBaseUrl() . '/' . ltrim($path, '/'); + if ($query !== []) { + $url .= '?' . http_build_query($query); + } + + $curl = curl_init($url); + if ($curl === false) { + throw new RuntimeException('Could not initialize GitHub API request.'); + } + + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method)); + curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 3); + curl_setopt($curl, CURLOPT_TIMEOUT, 10); + curl_setopt($curl, CURLOPT_NOSIGNAL, true); + curl_setopt($curl, CURLOPT_HTTPHEADER, [ + 'Accept: application/vnd.github+json', + 'Authorization: Bearer ' . $token, + 'User-Agent: Truckwash-Release-Manager', + 'X-GitHub-Api-Version: 2022-11-28', + ]); + + $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('GitHub API request failed: ' . $error); + } + + $decoded = []; + if (trim((string)$raw) !== '') { + $decodedJson = json_decode((string)$raw, true); + $decoded = is_array($decodedJson) ? $decodedJson : ['raw' => (string)$raw]; + } + + if ($status < 200 || $status >= 300) { + $message = is_array($decoded) + ? (string)($decoded['message'] ?? $decoded['error'] ?? ('HTTP ' . $status)) + : ('HTTP ' . $status); + throw new RuntimeException('GitHub API request failed: ' . $message); + } + + return $decoded; + } + + private function publicGithubRepository(array $row): array + { + $fullName = self::normalizeGithubRepositoryName((string)($row['full_name'] ?? '')); + return [ + 'id' => isset($row['id']) ? (int)$row['id'] : null, + 'name' => (string)($row['name'] ?? ''), + 'full_name' => $fullName, + 'private' => (bool)($row['private'] ?? false), + 'default_branch' => (string)($row['default_branch'] ?? self::DEFAULT_BRANCH), + 'description' => $row['description'] ?? null, + 'html_url' => $row['html_url'] ?? null, + 'clone_url' => $row['clone_url'] ?? null, + 'ssh_url' => $row['ssh_url'] ?? null, + 'pushed_at' => $row['pushed_at'] ?? null, + 'updated_at' => $row['updated_at'] ?? null, + ]; + } + + private function publicGithubBranch(array $row): array + { + return [ + 'name' => (string)($row['name'] ?? ''), + 'commit_sha' => $row['commit']['sha'] ?? null, + 'protected' => (bool)($row['protected'] ?? false), + ]; + } + + private function publicGithubCommit(array $row): array + { + $sha = (string)($row['sha'] ?? ''); + $message = (string)($row['commit']['message'] ?? ''); + $title = trim(strtok($message, "\n") ?: $message); + return [ + 'sha' => $sha, + 'short_sha' => substr($sha, 0, 12), + 'message' => $title, + 'author_name' => $row['commit']['author']['name'] ?? $row['author']['login'] ?? null, + 'authored_at' => $row['commit']['author']['date'] ?? null, + 'html_url' => $row['html_url'] ?? null, + ]; + } + + private function releaseSuggestions(): array + { + $channels = $this->listChannels(); + $targets = $this->listDeploymentTargets(); + $deployments = $this->listDeployments(50); + $versions = release_manager_schema_bootstrap::tablesExist() + ? $this->selectRows( + "SELECT app, repository, branch, deployed_url, build_url + FROM release_versions + WHERE repository IS NOT NULL OR branch IS NOT NULL OR deployed_url IS NOT NULL + ORDER BY created_at DESC + LIMIT 100" + ) + : []; + + $repositories = []; + $branches = [self::DEFAULT_BRANCH, 'main', 'develop', 'staging']; + $frontendUrls = []; + $apiUrls = []; + $healthUrls = []; + $loadBalancerDomains = []; + $serviceUuids = []; + + foreach ([ + $this->moduleConfigValue('Coolify', 'public_gateway_host', 'api-v2.truckwash.io'), + getenv('COOLIFY_PUBLIC_GATEWAY_HOST') ?: ($_SERVER['COOLIFY_PUBLIC_GATEWAY_HOST'] ?? null), + getenv('PUBLIC_GATEWAY_HOST') ?: ($_SERVER['PUBLIC_GATEWAY_HOST'] ?? null), + getenv('RELEASE_LOAD_BALANCER_DOMAIN') ?: ($_SERVER['RELEASE_LOAD_BALANCER_DOMAIN'] ?? null), + getenv('RELEASE_LOAD_BALANCER_DOMAINS') ?: ($_SERVER['RELEASE_LOAD_BALANCER_DOMAINS'] ?? null), + ] as $domain) { + $this->appendDomainSuggestion($loadBalancerDomains, $domain); + } + + foreach (array_merge($targets, $deployments, $versions) as $row) { + $this->appendSuggestion($repositories, $row['repository'] ?? null); + $this->appendSuggestion($branches, $row['branch'] ?? null); + $this->appendSuggestion($healthUrls, $row['health_url'] ?? null); + $this->appendSuggestion($healthUrls, $row['deployment_url'] ?? null); + $this->appendSuggestion($healthUrls, $row['deployed_url'] ?? null); + $this->appendSuggestion($serviceUuids, $row['coolify_service_uuid'] ?? null); + } + + foreach ($channels as $channel) { + $slug = (string)($channel['slug'] ?? ''); + $this->appendSuggestion($branches, $slug !== '' ? 'release/' . $slug : null); + $this->appendSuggestion($frontendUrls, $channel['frontend_base_url'] ?? null); + $this->appendSuggestion($apiUrls, $channel['api_base_url'] ?? null); + if (!empty($channel['frontend_base_url'])) { + $this->appendSuggestion($healthUrls, rtrim((string)$channel['frontend_base_url'], '/') . '/health'); + } + if (!empty($channel['api_base_url'])) { + $this->appendSuggestion($healthUrls, rtrim((string)$channel['api_base_url'], '/') . '/ping'); + } + } + + foreach ([ + 'GITHUB_REPOSITORY', + 'RELEASE_FRONTEND_REPOSITORY', + 'RELEASE_API_REPOSITORY', + 'FRONTEND_GITHUB_REPOSITORY', + 'API_GITHUB_REPOSITORY', + ] as $key) { + $this->appendSuggestion($repositories, getenv($key) ?: ($_SERVER[$key] ?? null)); + } + + foreach (['GITHUB_REF_NAME', 'RELEASE_BRANCH', 'FRONTEND_BRANCH', 'API_BRANCH'] as $key) { + $this->appendSuggestion($branches, getenv($key) ?: ($_SERVER[$key] ?? null)); + } + + foreach (['FRONTEND_URL', 'APP_URL', 'VITE_APP_URL'] as $key) { + $this->appendSuggestion($frontendUrls, getenv($key) ?: ($_SERVER[$key] ?? null)); + } + foreach (['API_URL', 'BACKEND_URL', 'PUBLIC_API_URL'] as $key) { + $this->appendSuggestion($apiUrls, getenv($key) ?: ($_SERVER[$key] ?? null)); + } + + $origin = trim((string)($_SERVER['HTTP_ORIGIN'] ?? '')); + if ($origin !== '') { + $this->appendSuggestion($frontendUrls, $origin); + } + $host = trim((string)($_SERVER['HTTP_HOST'] ?? '')); + if ($host !== '') { + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $this->appendSuggestion($apiUrls, $scheme . '://' . $host); + } + + $coolifyInstances = []; + $coolifyProjects = []; + $coolifyServices = []; + $coolifyGithubApps = []; + if ($this->tableExists('coolify_instances')) { + $instanceRows = $this->selectRows( + 'SELECT id, label, base_url, api_token_secret, status, default_project_uuid, default_environment_uuid, default_environment_name, default_server_uuid + FROM coolify_instances + WHERE deleted_at IS NULL + ORDER BY status = \'ok\' DESC, label' + ); + $coolifyInstances = array_map(static function (array $row): array { + return [ + 'id' => (int)($row['id'] ?? 0), + 'label' => (string)($row['label'] ?? ''), + 'base_url' => (string)($row['base_url'] ?? ''), + 'status' => (string)($row['status'] ?? 'unknown'), + 'default_project_uuid' => $row['default_project_uuid'] ?? null, + 'default_environment_uuid' => $row['default_environment_uuid'] ?? null, + 'default_environment_name' => $row['default_environment_name'] ?? null, + 'default_server_uuid' => $row['default_server_uuid'] ?? null, + ]; + }, $instanceRows); + + foreach ($instanceRows as $instanceRow) { + foreach ($this->coolifyProjectSuggestions($instanceRow) as $project) { + $coolifyProjects[] = $project; + } + foreach ($this->coolifyGithubAppSuggestions($instanceRow) as $githubApp) { + $coolifyGithubApps[] = $githubApp; + } + foreach ($this->coolifyServiceSuggestions($instanceRow) as $service) { + $coolifyServices[] = $service; + $this->appendSuggestion($serviceUuids, $service['uuid'] ?? null); + foreach (($service['urls'] ?? []) as $url) { + $this->appendSuggestion($healthUrls, $url); + } + } + } + } + + $channelPresets = [ + [ + 'slug' => 'stable', + 'name' => 'Stable', + 'description' => 'Default production release channel.', + 'rollout_percent' => 100, + 'default_channel' => true, + 'replay_enabled' => false, + 'capture_level' => 'metadata', + 'retention_days' => 14, + ], + [ + 'slug' => 'canary', + 'name' => 'Canary', + 'description' => 'Small early-access channel for validating a release before broad rollout.', + 'rollout_percent' => 5, + 'default_channel' => false, + 'replay_enabled' => true, + 'capture_level' => 'full_redacted', + 'retention_days' => 7, + ], + [ + 'slug' => 'beta', + 'name' => 'Beta', + 'description' => 'Customer or staff opt-in channel for release candidate validation.', + 'rollout_percent' => 0, + 'default_channel' => false, + 'replay_enabled' => true, + 'capture_level' => 'metadata', + 'retention_days' => 14, + ], + [ + 'slug' => 'internal', + 'name' => 'Internal', + 'description' => 'Staff-only channel for internal verification and support replay.', + 'rollout_percent' => 0, + 'default_channel' => false, + 'replay_enabled' => true, + 'capture_level' => 'full_redacted', + 'retention_days' => 14, + ], + ]; + + $frontendRepository = $this->firstSuggestion($repositories, ['front-end', 'frontend', 'vue']) ?? ($repositories[0] ?? ''); + $apiRepository = $this->firstSuggestion($repositories, ['backend', 'api', 'php']) ?? ($repositories[1] ?? $repositories[0] ?? ''); + + return [ + 'github_token_configured' => $this->hasGithubApiToken(), + 'github_api_url' => $this->githubApiBaseUrl(), + 'repositories' => array_values($repositories), + 'branches' => array_values($branches), + 'frontend_base_urls' => array_values($frontendUrls), + 'api_base_urls' => array_values($apiUrls), + 'health_urls' => array_values($healthUrls), + 'load_balancer_domains' => array_values($loadBalancerDomains), + 'coolify_instances' => $coolifyInstances, + 'coolify_projects' => $coolifyProjects, + 'coolify_github_apps' => $coolifyGithubApps, + 'coolify_services' => $coolifyServices, + 'coolify_service_uuids' => array_values($serviceUuids), + 'channel_presets' => $channelPresets, + 'target_presets' => [ + [ + 'label' => 'Frontend target', + 'app' => 'frontend', + 'repository' => $frontendRepository, + 'branch' => $branches[0] ?? self::DEFAULT_BRANCH, + 'health_url' => $healthUrls[0] ?? '', + 'auto_deploy' => true, + ], + [ + 'label' => 'API target', + 'app' => 'api', + 'repository' => $apiRepository, + 'branch' => $branches[0] ?? self::DEFAULT_BRANCH, + 'health_url' => $this->firstSuggestion($healthUrls, ['/ping']) + ?? $this->firstSuggestion($healthUrls, ['/health']) + ?? '', + 'auto_deploy' => true, + ], + ], + 'setup_steps' => [ + ['key' => 'channels', 'done' => count($channels) > 0], + ['key' => 'targets', 'done' => count($targets) > 0], + ['key' => 'deployments', 'done' => count($deployments) > 0], + ['key' => 'timeline', 'done' => (int)($this->timelineSummary()['events'] ?? 0) > 0], + ], + ]; + } + + private function appendSuggestion(array &$values, mixed $value): void + { + $value = trim((string)($value ?? '')); + if ($value === '') { + return; + } + foreach (preg_split('/\s*,\s*/', $value) ?: [] as $part) { + $part = trim($part); + if ($part !== '' && !in_array($part, $values, true)) { + $values[] = $part; + } + } + } + + private function appendDomainSuggestion(array &$values, mixed $value): void + { + $value = trim((string)($value ?? '')); + if ($value === '') { + return; + } + + foreach (preg_split('/\s*,\s*/', $value) ?: [] as $part) { + $domain = self::domainSuggestionHost($part); + if ($domain !== null && !in_array($domain, $values, true)) { + $values[] = $domain; + } + } + } + + private static function domainSuggestionHost(mixed $value): ?string + { + $raw = trim((string)($value ?? '')); + if ($raw === '') { + return null; + } + + $candidate = preg_match('#^https?://#i', $raw) === 1 ? $raw : 'https://' . $raw; + $host = parse_url($candidate, PHP_URL_HOST); + $port = parse_url($candidate, PHP_URL_PORT); + $host = strtolower(trim((string)$host, "[] \t\n\r\0\x0B.")); + + if ( + $host === '' + || $port !== null + || $host === 'localhost' + || str_ends_with($host, '.localhost') + || str_contains($host, '/') + || filter_var($host, FILTER_VALIDATE_IP) !== false + ) { + return null; + } + + return $host; + } + + private function firstSuggestion(array $values, array $needles): ?string + { + foreach ($values as $value) { + foreach ($needles as $needle) { + if (stripos((string)$value, $needle) !== false) { + return (string)$value; + } + } + } + return null; + } + + private function coolifyProjectSuggestions(array $instance): array + { + $tokenSecret = trim((string)($instance['api_token_secret'] ?? '')); + if ($tokenSecret === '') { + return []; + } + + try { + $token = replication_secret_box::decrypt($tokenSecret); + $projects = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listProjects(); + } catch (Throwable) { + return []; + } + + $suggestions = []; + foreach ($this->payloadRows($projects) as $row) { + if (!is_array($row)) { + continue; + } + $uuid = trim((string)($row['uuid'] ?? '')); + if ($uuid === '') { + continue; + } + $suggestions[] = [ + 'instance_id' => (int)($instance['id'] ?? 0), + 'instance_label' => (string)($instance['label'] ?? ''), + 'uuid' => $uuid, + 'name' => (string)($row['name'] ?? $uuid), + 'description' => (string)($row['description'] ?? ''), + 'default' => $uuid === trim((string)($instance['default_project_uuid'] ?? '')), + ]; + } + + return array_slice($suggestions, 0, 50); + } + + private function coolifyGithubAppSuggestions(array $instance): array + { + $tokenSecret = trim((string)($instance['api_token_secret'] ?? '')); + if ($tokenSecret === '') { + return []; + } + + try { + $token = replication_secret_box::decrypt($tokenSecret); + $apps = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listGithubApps(); + } catch (Throwable) { + return []; + } + + $suggestions = []; + foreach ($this->payloadRows($apps) as $row) { + if (!is_array($row)) { + continue; + } + $uuid = trim((string)($row['uuid'] ?? '')); + if ($uuid === '') { + continue; + } + $suggestions[] = [ + 'instance_id' => (int)($instance['id'] ?? 0), + 'instance_label' => (string)($instance['label'] ?? ''), + 'uuid' => $uuid, + 'name' => (string)($row['name'] ?? $uuid), + 'organization' => (string)($row['organization'] ?? ''), + 'type' => (string)($row['type'] ?? ''), + 'is_system_wide' => (bool)($row['is_system_wide'] ?? false), + 'html_url' => (string)($row['html_url'] ?? ''), + ]; + } + + return array_slice($suggestions, 0, 50); + } + + private function coolifyServiceSuggestions(array $instance): array + { + $tokenSecret = trim((string)($instance['api_token_secret'] ?? '')); + if ($tokenSecret === '') { + return []; + } + + try { + $token = replication_secret_box::decrypt($tokenSecret); + $services = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listServices(); + } catch (Throwable) { + return []; + } + + $rows = $this->payloadRows($services); + $suggestions = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $uuid = trim((string)($row['uuid'] ?? $row['id'] ?? '')); + if ($uuid === '') { + continue; + } + $urls = []; + foreach (['fqdn', 'domain', 'url'] as $key) { + $this->appendSuggestion($urls, $row[$key] ?? null); + } + foreach (['urls', 'domains'] as $key) { + if (!is_array($row[$key] ?? null)) { + continue; + } + foreach ($row[$key] as $url) { + if (is_array($url)) { + $this->appendSuggestion($urls, $url['url'] ?? $url['domain'] ?? $url['fqdn'] ?? null); + } else { + $this->appendSuggestion($urls, $url); + } + } + } + + $suggestions[] = [ + 'instance_id' => (int)($instance['id'] ?? 0), + 'instance_label' => (string)($instance['label'] ?? ''), + 'uuid' => $uuid, + 'name' => (string)($row['name'] ?? $row['service_name'] ?? $uuid), + 'status' => (string)($row['status'] ?? $row['deployment_status'] ?? 'unknown'), + 'urls' => array_values($urls), + ]; + } + + return array_slice($suggestions, 0, 50); + } + + private function payloadRows(array $payload): array + { + if ($payload === []) { + return []; + } + if (array_keys($payload) === range(0, count($payload) - 1)) { + return $payload; + } + foreach (['data', 'services', 'projects', 'servers', 'github_apps', 'results'] as $key) { + if (is_array($payload[$key] ?? null)) { + return $this->payloadRows($payload[$key]); + } + } + return []; + } + + private function normalizeChannelInput(array $input, bool $creating): array + { + $slug = self::safeSlug((string)($input['slug'] ?? '')); + if ($slug === '') { + throw new RuntimeException('Release channel slug is required.'); + } + + $name = trim((string)($input['name'] ?? ($creating ? '' : $slug))); + if ($name === '') { + throw new RuntimeException('Release channel name is required.'); + } + + $retention = (int)($input['retention_days'] ?? 14); + return [ + 'slug' => $slug, + 'name' => substr($name, 0, 128), + 'description' => trim((string)($input['description'] ?? '')) ?: null, + 'enabled' => $this->toBool($input['enabled'] ?? true) ? 1 : 0, + 'default_channel' => $this->toBool($input['default_channel'] ?? false) ? 1 : 0, + 'rollout_percent' => max(0, min(100, (float)($input['rollout_percent'] ?? 0))), + 'frontend_base_url' => trim((string)($input['frontend_base_url'] ?? '')) ?: null, + 'api_base_url' => trim((string)($input['api_base_url'] ?? '')) ?: null, + 'replay_enabled' => $this->toBool($input['replay_enabled'] ?? false) ? 1 : 0, + 'capture_level' => $this->normalizeCaptureLevel((string)($input['capture_level'] ?? 'metadata')), + 'retention_days' => max(1, min(365, $retention > 0 ? $retention : 14)), + 'metadata' => is_array($input['metadata'] ?? null) ? $input['metadata'] : self::jsonDecode($input['metadata_json'] ?? null), + ]; + } + + private function channelFromInput(array $input): array + { + $id = $this->nullablePositiveInt($input['channel_id'] ?? null); + if ($id !== null) { + return $this->getChannel($id); + } + + $slug = self::safeSlug((string)($input['channel_slug'] ?? $input['channel'] ?? '')); + if ($slug !== '') { + $channel = $this->findChannelBySlug(self::channelSlugForRoute($slug)); + if ($channel !== null) { + return $channel; + } + } + + throw new RuntimeException('Release channel is required.'); + } + + private function deploymentTargetFromInput(array $input, int $channelId, string $app): ?array + { + $targetId = $this->nullablePositiveInt($input['target_id'] ?? null); + if ($targetId !== null) { + return $this->getDeploymentTarget($targetId); + } + + return $this->selectOne( + "SELECT * FROM release_deployment_targets + WHERE deleted_at IS NULL AND channel_id = ? AND app = ? + ORDER BY auto_deploy DESC, id DESC + LIMIT 1", + 'is', + [$channelId, $app] + ); + } + + private function normalizeServiceSetMode(string $value): string + { + $mode = strtolower(trim($value)); + if (!in_array($mode, self::SERVICE_SET_MODES, true)) { + throw new RuntimeException('Release service set mode must be attach_existing, clone_existing, fresh_empty, or isolated_stack.'); + } + return $mode; + } + + private function channelFromInputOrDefault(array $input, ?array $source = null): array + { + foreach (['channel_id', 'channel_slug', 'channel'] as $key) { + if (array_key_exists($key, $input) && trim((string)$input[$key]) !== '') { + return $this->channelFromInput($input); + } + } + + if ($source !== null && !empty($source['channel_id'])) { + return $this->getChannel((int)$source['channel_id']); + } + + return $this->defaultChannel(); + } + + private function serviceSetTargetIdFromInput(array $input, string $app, ?array $source): ?int + { + $aliases = $app === 'api' + ? ['api_target_id', 'php_target_id', 'backend_target_id'] + : ['frontend_target_id']; + $targets = is_array($input['targets'] ?? null) ? $input['targets'] : []; + if (is_array($targets[$app] ?? null)) { + foreach (['target_id', 'id'] as $key) { + $aliases[] = $app . '.' . $key; + } + } + + foreach ($aliases as $key) { + $value = str_contains($key, '.') + ? ($targets[$app][substr($key, strpos($key, '.') + 1)] ?? null) + : ($input[$key] ?? null); + $id = $this->nullablePositiveInt($value); + if ($id === null) { + continue; + } + $target = $this->getDeploymentTarget($id); + if ((string)($target['app'] ?? '') !== $app) { + throw new RuntimeException(sprintf('Selected %s target does not match the requested app.', $app)); + } + return $id; + } + + $sourceKey = $app . '_target_id'; + return $source !== null ? $this->nullablePositiveInt($source[$sourceKey] ?? null) : null; + } + + private function serviceSetDataTargetIdFromInput(array $input, string $kind, ?array $source): ?int + { + $dataTargets = is_array($input['data_targets'] ?? null) ? $input['data_targets'] : []; + $value = $input[$kind . '_coolify_target_id'] + ?? $input[$kind . '_target_id'] + ?? $dataTargets[$kind . '_coolify_target_id'] + ?? $dataTargets[$kind . '_target_id'] + ?? $dataTargets[$kind] + ?? null; + $id = $this->nullablePositiveInt($value); + if ($id !== null) { + $target = $this->nullableCoolifyTarget($id); + if ($target !== null && (string)($target['kind'] ?? '') !== $kind) { + throw new RuntimeException(sprintf('Selected %s data service target has the wrong replica kind.', $kind)); + } + return $id; + } + + $sourceKey = $kind . '_coolify_target_id'; + return $source !== null ? $this->nullablePositiveInt($source[$sourceKey] ?? null) : null; + } + + private function serviceSetInputHasExplicitDataTargets(array $input): bool + { + $dataTargets = is_array($input['data_targets'] ?? null) ? $input['data_targets'] : []; + foreach (self::STACK_DATA_KINDS as $kind) { + foreach ([ + $input[$kind . '_coolify_target_id'] ?? null, + $input[$kind . '_target_id'] ?? null, + $dataTargets[$kind . '_coolify_target_id'] ?? null, + $dataTargets[$kind . '_target_id'] ?? null, + $dataTargets[$kind] ?? null, + ] as $value) { + if ($this->nullablePositiveInt($value) !== null) { + return true; + } + } + } + return false; + } + + private function isBetaChannel(array $channel): bool + { + return self::safeSlug((string)($channel['slug'] ?? '')) === 'beta'; + } + + private function channelUsesProductionServices(array $channel): bool + { + return in_array( + self::safeSlug((string)($channel['slug'] ?? '')), + self::PRODUCTION_SERVICE_CHANNELS, + true + ); + } + + private function serviceSetDataPolicy(?array $serviceSet): string + { + if ($serviceSet === null) { + return self::PRODUCTION_DATA_POLICY; + } + + $mode = strtolower(trim((string)($serviceSet['mode'] ?? ''))); + if (in_array($mode, ['clone_existing', 'fresh_empty', 'isolated_stack'], true)) { + return $mode; + } + if (in_array($mode, ['', 'attach_existing', self::PRODUCTION_DATA_POLICY], true)) { + return self::PRODUCTION_DATA_POLICY; + } + + $metadata = is_array($serviceSet['metadata'] ?? null) + ? $serviceSet['metadata'] + : self::jsonDecode($serviceSet['metadata_json'] ?? null); + $policy = strtolower(trim((string)($serviceSet['data_policy'] ?? $serviceSet['data_service_mode'] ?? $metadata['data_policy'] ?? $metadata['data_service_mode'] ?? ''))); + if ($policy === self::PRODUCTION_DATA_POLICY) { + return self::PRODUCTION_DATA_POLICY; + } + + return $policy !== '' ? $policy : self::PRODUCTION_DATA_POLICY; + } + + private function assertBetaProductionDataPolicy(array $channel, array $serviceSet): void + { + if (!$this->isBetaChannel($channel)) { + return; + } + + if ($this->serviceSetDataPolicy($serviceSet) !== self::PRODUCTION_DATA_POLICY) { + throw new RuntimeException('Beta release bundles must use production-shared data services.'); + } + } + + private function assertBetaDataSourceChannel(?array $source): void + { + if ($source === null) { + return; + } + + $slug = self::safeSlug((string)($source['channel_slug'] ?? '')); + if (!in_array($slug, self::BETA_PRODUCTION_DATA_SOURCE_CHANNELS, true)) { + throw new RuntimeException('Beta data-only service sets can copy data targets only from Stable/Master production service sets.'); + } + } + + private function assertServiceSetTargetBelongsToChannel(?int $targetId, string $app, array $channel): void + { + if ($targetId === null) { + return; + } + + $target = $this->getDeploymentTarget($targetId); + if ((string)($target['app'] ?? '') !== $app) { + throw new RuntimeException(sprintf('Selected %s target does not match the requested app.', $app)); + } + if ((int)($target['channel_id'] ?? 0) !== (int)($channel['id'] ?? 0)) { + throw new RuntimeException(sprintf('Beta production-data service sets require %s targets from the beta channel.', $app)); + } + } + + private function assertIsolatedStackTarget(?int $targetId, string $app, bool $allowCreatedService = false): void + { + if ($targetId === null) { + throw new RuntimeException(sprintf('Isolated stack deployments require a new %s Coolify target.', $app)); + } + + $target = $this->getDeploymentTarget($targetId); + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + if ((string)($target['app'] ?? '') !== $app) { + throw new RuntimeException(sprintf('Isolated stack %s target does not match the requested app.', $app)); + } + if ($this->nullablePositiveInt($target['coolify_instance_id'] ?? null) === null) { + throw new RuntimeException(sprintf('Isolated stack %s target must select a Coolify instance.', $app)); + } + if (trim((string)($target['coolify_service_uuid'] ?? '')) !== '' && !$allowCreatedService) { + throw new RuntimeException(sprintf('Isolated stack %s target must not point at an existing Coolify service.', $app)); + } + + if (!$this->toBool($context['coolify_auto_create'] ?? false)) { + throw new RuntimeException(sprintf('Isolated stack %s target must create a new Coolify service.', $app)); + } + if (!$this->toBool($context['isolated_stack'] ?? false)) { + throw new RuntimeException(sprintf('Isolated stack %s target must be marked as isolated.', $app)); + } + } + + private function assertIsolatedStackDataTarget(?int $targetId, string $kind): void + { + if ($targetId === null) { + throw new RuntimeException(sprintf('Isolated stack deployments require a new %s data target.', $kind)); + } + + $target = $this->nullableCoolifyTarget($targetId); + if ($target === null) { + throw new RuntimeException(sprintf('Selected %s isolated data target was not found.', $kind)); + } + if ((string)($target['kind'] ?? '') !== $kind) { + throw new RuntimeException(sprintf('Selected %s isolated data target has the wrong kind.', $kind)); + } + + $options = is_array($target['options'] ?? null) ? $target['options'] : []; + if (!$this->toBool($options['isolated_stack'] ?? false)) { + throw new RuntimeException(sprintf('Selected %s data target is not marked as an isolated stack target.', $kind)); + } + if ($this->toBool($options['production_data_attached'] ?? true)) { + throw new RuntimeException(sprintf('Selected %s data target must not attach production data.', $kind)); + } + if (!$this->toBool($options['skip_replication_provisioning'] ?? false)) { + throw new RuntimeException(sprintf('Selected %s data target must skip production replication provisioning.', $kind)); + } + } + + private function createIsolatedStackDataTarget( + string $kind, + array $input, + array $channel, + string $serviceSetName, + ?int $frontendTargetId, + ?int $apiTargetId, + ?int $actorUserId + ): int { + if (!class_exists(coolify_manager::class) && function_exists('app_require')) { + app_require('classes/coolify_manager.php'); + } + if (!class_exists(coolify_manager::class)) { + throw new RuntimeException('Coolify integration is required to create isolated stack data services.'); + } + + $placementTarget = $this->isolatedStackPlacementTarget($apiTargetId, $frontendTargetId); + $context = self::jsonDecode($placementTarget['deploy_context_json'] ?? null); + $dataServicesInput = is_array($input['data_services'] ?? null) ? $input['data_services'] : []; + $dataInput = is_array($dataServicesInput[$kind] ?? null) ? $dataServicesInput[$kind] : []; + $placementContext = array_replace($context, $dataInput); + $instanceId = $this->nullablePositiveInt($placementTarget['coolify_instance_id'] ?? null); + if ($instanceId === null) { + throw new RuntimeException('Isolated stack data services require a Coolify instance.'); + } + + $instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL', 'i', [$instanceId]); + if ($instance === null) { + throw new RuntimeException('Coolify instance for isolated stack data services was not found.'); + } + + $serverUuid = $this->releaseCoolifyServerUuid($placementContext, $instance); + if ($serverUuid === '') { + throw new RuntimeException('Release Manager could not resolve a Coolify server UUID for isolated data services.'); + } + $placementTarget['channel_slug'] = $placementTarget['channel_slug'] ?? $channel['slug'] ?? ''; + $environment = $this->releaseCoolifyEnvironment($placementTarget, $placementContext, $instance); + + $stackSlug = self::safeSlug($serviceSetName !== '' ? $serviceSetName : ((string)($channel['slug'] ?? 'release') . '-isolated-stack')); + $serviceName = substr('release-' . ($stackSlug !== '' ? $stackSlug : 'isolated-stack') . '-' . $kind, 0, 64); + $payload = array_replace($dataInput, [ + 'kind' => $kind, + 'role' => 'replica', + 'instance_id' => $instanceId, + 'server_uuid' => $serverUuid, + 'project_uuid' => trim((string)($placementContext['coolify_project_uuid'] ?? $placementContext['project_uuid'] ?? $instance['default_project_uuid'] ?? '')), + 'environment_uuid' => $environment['uuid'] ?? '', + 'environment_name' => $environment['name'], + 'destination_uuid' => trim((string)($placementContext['coolify_destination_uuid'] ?? $placementContext['destination_uuid'] ?? $instance['default_destination_uuid'] ?? '')), + 'label' => $serviceName, + 'service_name' => $serviceName, + 'resource_name' => $serviceName, + 'isolated_stack' => true, + 'skip_replication_provisioning' => true, + 'deploy' => $this->toBool($input['deploy_data_targets'] ?? $input['deploy_isolated_data_targets'] ?? true), + 'options' => [ + 'isolated_stack' => true, + 'skip_replication_provisioning' => true, + 'production_data_attached' => false, + 'release_service_set_name' => $serviceSetName, + 'release_channel_slug' => (string)($channel['slug'] ?? ''), + ], + ]); + + $created = (new coolify_manager())->createTarget($payload, $actorUserId); + $targetId = $this->nullablePositiveInt($created['target']['id'] ?? null); + if ($targetId === null) { + throw new RuntimeException(sprintf('Coolify did not return a %s isolated data target id.', $kind)); + } + + $this->assertIsolatedStackDataTarget($targetId, $kind); + return $targetId; + } + + private function isolatedStackPlacementTarget(?int $apiTargetId, ?int $frontendTargetId): array + { + foreach ([$apiTargetId, $frontendTargetId] as $targetId) { + if ($targetId === null) { + continue; + } + $target = $this->getDeploymentTarget($targetId); + if ($this->nullablePositiveInt($target['coolify_instance_id'] ?? null) !== null) { + return $target; + } + } + + throw new RuntimeException('Isolated stack data services require a frontend or API Coolify target.'); + } + + private function uniqueServiceSetSlug(string $slug): string + { + $base = $slug !== '' ? $slug : 'service-set-' . date('Ymd-His'); + $candidate = substr($base, 0, 64); + $suffix = 2; + while ($this->selectOne('SELECT id FROM release_service_sets WHERE slug = ? LIMIT 1', 's', [$candidate]) !== null) { + $tail = '-' . $suffix; + $candidate = substr($base, 0, 64 - strlen($tail)) . $tail; + $suffix++; + } + return $candidate; + } + + private function serviceSetStatus(string $mode, ?int $frontendTargetId, ?int $apiTargetId, array $dataTargets): string + { + $hasCode = $frontendTargetId !== null && $apiTargetId !== null; + $hasData = !in_array(null, $dataTargets, true); + if ($mode === 'isolated_stack') { + return $hasCode && $hasData ? 'isolated_stack' : 'needs_isolated_targets'; + } + if ($mode === 'attach_existing') { + return $hasCode ? 'ready' : 'needs_configuration'; + } + if ($hasCode && $hasData) { + return $mode === 'clone_existing' ? 'provisioning' : 'ready'; + } + if ($mode === 'fresh_empty') { + return 'isolated_empty'; + } + return $mode === 'clone_existing' ? 'needs_clone_targets' : 'needs_configuration'; + } + + private function replicaProvisioningPlan(string $mode, ?array $source, array $dataTargets): array + { + $plan = []; + foreach (self::STACK_DATA_KINDS as $kind) { + $sourceTargetId = $source !== null ? $this->nullablePositiveInt($source[$kind . '_coolify_target_id'] ?? null) : null; + $sourceTarget = $this->nullableCoolifyTarget($sourceTargetId); + $target = $this->nullableCoolifyTarget($dataTargets[$kind] ?? null); + $plan[$kind] = [ + 'action' => match ($mode) { + 'clone_existing' => 'clone_replica_from_source', + 'isolated_stack' => 'create_isolated_empty_stack_service', + 'fresh_empty' => 'register_isolated_empty_service', + default => 'attach_existing_service', + }, + 'source_coolify_target_id' => $sourceTargetId, + 'source_replication_host_id' => $sourceTarget['replication']['id'] ?? null, + 'target_coolify_target_id' => $dataTargets[$kind] ?? null, + 'target_replication_host_id' => $target['replication']['id'] ?? null, + 'production_replication_attached' => $mode === 'attach_existing', + ]; + } + + return $plan; + } + + private function bundleAppInput(array $input, string $app, ?array $target, string $versionLabel): array + { + $appPayload = is_array($input[$app] ?? null) ? $input[$app] : []; + if ($app === 'api') { + $appPayload = array_replace( + is_array($input['php'] ?? null) ? $input['php'] : [], + is_array($input['backend'] ?? null) ? $input['backend'] : [], + $appPayload + ); + } + + $repository = trim((string)($appPayload['repository'] ?? $input[$app . '_repository'] ?? $target['repository'] ?? '')); + $normalizedRepository = self::normalizeGithubRepositoryName($repository); + if ($normalizedRepository !== '') { + $repository = $normalizedRepository; + } + if ($repository === '') { + throw new RuntimeException(sprintf('%s repository is required for bundle releases.', $app === 'api' ? 'PHP backend' : 'Frontend')); + } + + $branch = trim((string)($appPayload['branch'] ?? $input[$app . '_branch'] ?? $target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH; + $rawCommitSha = trim((string)($appPayload['commit_sha'] ?? $appPayload['commit'] ?? $input[$app . '_commit_sha'] ?? '')); + $commitMode = $this->normalizeCommitMode((string)($appPayload['commit_mode'] ?? $input[$app . '_commit_mode'] ?? ''), $rawCommitSha); + $commitSha = $commitMode === 'specific' && $rawCommitSha !== '' ? $rawCommitSha : null; + $githubAccess = $this->githubRepositoryAccess([ + 'repository' => $repository, + 'branch' => $branch, + 'commit_sha' => $commitSha, + 'commit_mode' => $commitMode, + ]); + if (($githubAccess['token_configured'] ?? false) && !($githubAccess['ok'] ?? false)) { + throw new RuntimeException('GitHub repository access test failed: ' . (string)($githubAccess['message'] ?? 'Repository is not accessible.')); + } + if (($githubAccess['ok'] ?? false) && !empty($githubAccess['commit_sha'])) { + $commitSha = (string)$githubAccess['commit_sha']; + $branch = (string)($githubAccess['branch'] ?? $branch); + } + + return [ + 'app' => $app, + 'repository' => $repository, + 'branch' => $branch, + 'commit_mode' => $commitMode, + 'commit_sha' => $commitSha, + 'version_label' => sprintf('%s-%s', $versionLabel, $app === 'api' ? 'php' : 'frontend'), + 'deployed_url' => is_array($target) ? $this->releaseTargetPublicBaseUrl($target) : null, + 'github_access' => $githubAccess, + ]; + } + + private function createBundleVersion(array $input, string $app): int + { + return $this->createVersion([ + 'app' => $app, + 'repository' => $input['repository'], + 'branch' => $input['branch'], + 'commit_sha' => $input['commit_sha'], + 'version_label' => $input['version_label'], + 'deployed_url' => $input['deployed_url'] ?? null, + 'status' => 'draft', + 'metadata' => [ + 'commit_mode' => $input['commit_mode'], + 'github_access' => $input['github_access'], + 'bundle_member' => true, + ], + ]); + } + + private function getChannel(int $id): array + { + $row = $this->selectOne('SELECT * FROM release_channels WHERE id = ? AND deleted_at IS NULL', 'i', [$id]); + if ($row === null) { + throw new RuntimeException('Release channel not found.'); + } + return $row; + } + + private function findChannelBySlug(string $slug): ?array + { + return $this->selectOne( + 'SELECT * FROM release_channels WHERE slug = ? AND deleted_at IS NULL LIMIT 1', + 's', + [$slug] + ); + } + + private function getVersion(int $id): array + { + return $this->selectOne('SELECT * FROM release_versions WHERE id = ?', 'i', [$id]) ?? []; + } + + private function getDeployment(int $id): array + { + $row = $this->selectOne( + "SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url + FROM release_deployments d + INNER JOIN release_channels c ON c.id = d.channel_id + LEFT JOIN release_versions v ON v.id = d.version_id + WHERE d.id = ?", + 'i', + [$id] + ); + if ($row === null) { + throw new RuntimeException('Release deployment not found.'); + } + return $row; + } + + private function getServiceSet(int $id): array + { + $row = $this->selectOne( + "SELECT s.*, c.slug AS channel_slug, c.name AS channel_name + FROM release_service_sets s + LEFT JOIN release_channels c ON c.id = s.channel_id + WHERE s.id = ? AND s.deleted_at IS NULL", + 'i', + [$id] + ); + if ($row === null) { + throw new RuntimeException('Release service set not found.'); + } + return $row; + } + + private function getBundle(int $id): array + { + $row = $this->selectOne( + "SELECT b.*, c.slug AS channel_slug, c.name AS channel_name, s.name AS service_set_name, s.slug AS service_set_slug + FROM release_bundles b + INNER JOIN release_channels c ON c.id = b.channel_id + INNER JOIN release_service_sets s ON s.id = b.service_set_id + WHERE b.id = ? AND b.deleted_at IS NULL", + 'i', + [$id] + ); + if ($row === null) { + throw new RuntimeException('Release bundle not found.'); + } + return $row; + } + + private function getDeploymentTarget(int $id): array + { + $row = $this->selectOne( + "SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, i.label AS coolify_instance_label + FROM release_deployment_targets t + INNER JOIN release_channels c ON c.id = t.channel_id + LEFT JOIN coolify_instances i ON i.id = t.coolify_instance_id + WHERE t.id = ? AND t.deleted_at IS NULL", + 'i', + [$id] + ); + if ($row === null) { + throw new RuntimeException('Release deployment target not found.'); + } + return $row; + } + + private function publicChannel(array $channel): array + { + return [ + 'id' => (int)($channel['id'] ?? 0), + 'slug' => (string)($channel['slug'] ?? ''), + 'route_slug' => self::routeSlugForChannel((string)($channel['slug'] ?? '')), + 'name' => (string)($channel['name'] ?? ''), + 'description' => $channel['description'] ?? null, + 'enabled' => (bool)((int)($channel['enabled'] ?? 0)), + 'default_channel' => (bool)((int)($channel['default_channel'] ?? 0)), + 'rollout_percent' => (float)($channel['rollout_percent'] ?? 0), + 'frontend_base_url' => $channel['frontend_base_url'] ?? null, + 'api_base_url' => $channel['api_base_url'] ?? null, + 'replay_enabled' => (bool)((int)($channel['replay_enabled'] ?? 0)), + 'capture_level' => (string)($channel['capture_level'] ?? 'metadata'), + 'retention_days' => (int)($channel['retention_days'] ?? 14), + 'metadata' => self::jsonDecode($channel['metadata_json'] ?? null), + 'created_at' => $channel['created_at'] ?? null, + 'updated_at' => $channel['updated_at'] ?? null, + ]; + } + + private function publicRuntimeChannel(array $channel): array + { + $public = $this->publicChannel($channel); + unset($public['frontend_base_url'], $public['api_base_url']); + return $public; + } + + private function publicRuntimeChannelOptions(array $channels): array + { + return array_map(function (array $channel): array { + $serviceChannel = $this->runtimeServiceChannelFor($channel); + return [ + 'channel' => $this->publicRuntimeChannel($channel), + 'service_channel' => $this->publicRuntimeChannel($serviceChannel), + 'versions' => $this->currentVersionsForChannel((int)($serviceChannel['id'] ?? 0)), + 'availability' => $this->channelAvailability($channel), + ]; + }, $channels); + } + + public static function publicAssignmentSubjectSuggestion(array $candidate): ?array + { + $subjectType = strtolower(trim((string)($candidate['subject_type'] ?? ''))); + if (!in_array($subjectType, self::SUBJECT_TYPES, true)) { + return null; + } + + $subjectId = self::safeIdentifier((string)($candidate['subject_id'] ?? ''), 64); + if ($subjectId === '') { + return null; + } + + $title = self::safeDisplayText($candidate['title'] ?? '', 120); + if ($title === '') { + $title = $subjectType . ':' . $subjectId; + } + $description = self::safeDisplayText($candidate['description'] ?? '', 180); + $icon = self::safeIconClass($candidate['icon'] ?? self::assignmentSubjectIcon($subjectType)); + $source = self::safeIdentifier((string)($candidate['source'] ?? $subjectType), 32) ?: $subjectType; + + return [ + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'label' => $description !== '' ? $title . ' - ' . $description : $title, + 'title' => $title, + 'description' => $description, + 'icon' => $icon, + 'source' => $source, + ]; + } + + private function searchAssignmentUsers(string $query, int $limit): array + { + $like = '%' . $query . '%'; + $rows = $this->selectRows( + "SELECT id, customer_number, display_name, email, phone_country_code, phone + FROM users + WHERE CAST(id AS CHAR) LIKE ? + OR CAST(customer_number AS CHAR) LIKE ? + OR display_name LIKE ? + OR email LIKE ? + ORDER BY id DESC + LIMIT ?", + 'ssssi', + [$like, $like, $like, $like, $limit] + ); + + return array_map(function (array $row): array { + $id = (string)($row['id'] ?? ''); + $customerNumber = trim((string)($row['customer_number'] ?? '')); + $displayName = self::safeDisplayText($row['display_name'] ?? '', 80); + $email = self::safeDisplayText($row['email'] ?? '', 80); + $parts = array_filter([ + $customerNumber !== '' ? 'Customer #' . $customerNumber : '', + $email, + $this->phoneLabel($row), + ]); + + return [ + 'subject_type' => 'user', + 'subject_id' => $id, + 'title' => $displayName !== '' ? $displayName : 'User #' . $id, + 'description' => implode(' / ', $parts), + 'icon' => self::assignmentSubjectIcon('user'), + 'source' => 'users', + ]; + }, $rows); + } + + private function searchAssignmentSubusers(string $query, int $limit): array + { + $like = '%' . $query . '%'; + $rows = $this->selectRows( + "SELECT id, username, name, email, phone_country_code, phone + FROM subusers + WHERE CAST(id AS CHAR) LIKE ? + OR username LIKE ? + OR name LIKE ? + OR email LIKE ? + OR CAST(phone AS CHAR) LIKE ? + ORDER BY id DESC + LIMIT ?", + 'sssssi', + [$like, $like, $like, $like, $like, $limit] + ); + + return array_map(function (array $row): array { + $id = (string)($row['id'] ?? ''); + $name = self::safeDisplayText($row['name'] ?? '', 80); + $username = self::safeDisplayText($row['username'] ?? '', 80); + $email = self::safeDisplayText($row['email'] ?? '', 80); + $parts = array_filter([ + $username !== '' ? '@' . ltrim($username, '@') : '', + $email, + $this->phoneLabel($row), + ]); + + return [ + 'subject_type' => 'subuser', + 'subject_id' => $id, + 'title' => $name !== '' ? $name : 'Subuser #' . $id, + 'description' => implode(' / ', $parts), + 'icon' => self::assignmentSubjectIcon('subuser'), + 'source' => 'subusers', + ]; + }, $rows); + } + + private function searchAssignmentCustomers(string $query, int $limit): array + { + try { + $result = (new economicCustomers())->listCustomers(1, $limit, $query, null); + } catch (Throwable) { + return []; + } + + $customers = is_array($result->collection ?? null) ? $result->collection : []; + return array_map(static function (object $customer): array { + $customerNumber = (string)($customer->customerNumber ?? $customer->customer_number ?? ''); + $name = self::safeDisplayText($customer->name ?? $customer->customer_name ?? '', 100); + $email = self::safeDisplayText($customer->email ?? '', 80); + $city = self::safeDisplayText($customer->city ?? '', 80); + $parts = array_filter([ + $customerNumber !== '' ? 'Customer #' . $customerNumber : '', + $email, + $city, + ]); + + return [ + 'subject_type' => 'customer', + 'subject_id' => $customerNumber, + 'title' => $name !== '' ? $name : 'Customer #' . $customerNumber, + 'description' => implode(' / ', $parts), + 'icon' => self::assignmentSubjectIcon('customer'), + 'source' => 'customers', + ]; + }, $customers); + } + + private static function normalizeAssignmentSubjectSearch(mixed $value): string + { + $query = self::safeDisplayText($value, 80); + return trim($query); + } + + private static function normalizeAssignmentSubjectLimit(mixed $value): int + { + $limit = (int)$value; + if ($limit <= 0) { + return 5; + } + return min(10, max(1, $limit)); + } + + private static function safeDisplayText(mixed $value, int $maxLength): string + { + $text = trim(strip_tags((string)$value)); + $text = preg_replace('/\s+/', ' ', $text) ?? ''; + return substr($text, 0, max(1, $maxLength)); + } + + private static function safeIconClass(mixed $value): string + { + $icon = trim((string)$value); + if (!preg_match('/^[a-z0-9 _-]+$/i', $icon)) { + return 'fas fa-tag'; + } + return $icon; + } + + private static function assignmentSubjectIcon(string $subjectType): string + { + return match ($subjectType) { + 'customer' => 'fas fa-building', + 'subuser' => 'fas fa-id-badge', + default => 'fas fa-user', + }; + } + + private function phoneLabel(array $row): string + { + $countryCode = trim((string)($row['phone_country_code'] ?? '')); + $phone = trim((string)($row['phone'] ?? '')); + if ($phone === '') { + return ''; + } + return $countryCode !== '' ? '+' . $countryCode . ' ' . $phone : $phone; + } + + private function publicVersion(?array $version): ?array + { + if (!$version || empty($version['id'])) { + return null; + } + + $metadata = self::jsonDecode($version['metadata_json'] ?? null); + $commit = $this->versionGithubCommit(['metadata' => $metadata]); + + return [ + 'id' => (int)$version['id'], + 'app' => (string)$version['app'], + 'repository' => $version['repository'] ?? null, + 'branch' => $version['branch'] ?? null, + 'commit_sha' => $version['commit_sha'] ?? null, + 'commit' => $commit, + 'commit_authored_at' => $commit['authored_at'] ?? null, + 'tag' => $version['tag'] ?? null, + 'version_label' => $version['version_label'] ?? null, + 'build_url' => $version['build_url'] ?? null, + 'artifact_url' => $version['artifact_url'] ?? null, + 'deployed_url' => $version['deployed_url'] ?? null, + 'status' => (string)($version['status'] ?? 'unknown'), + 'metadata' => $metadata, + 'created_at' => $version['created_at'] ?? null, + 'deployed_at' => $version['deployed_at'] ?? null, + ]; + } + + private function publicAssignment(array $assignment): array + { + return [ + 'id' => (int)($assignment['id'] ?? 0), + 'subject_type' => (string)($assignment['subject_type'] ?? ''), + 'subject_id' => (string)($assignment['subject_id'] ?? ''), + 'channel_id' => (int)($assignment['channel_id'] ?? 0), + 'channel_slug' => (string)($assignment['channel_slug'] ?? ''), + 'channel_name' => (string)($assignment['channel_name'] ?? ''), + 'reason' => $assignment['reason'] ?? null, + 'expires_at' => $assignment['expires_at'] ?? null, + 'actor_user_id' => isset($assignment['actor_user_id']) ? (int)$assignment['actor_user_id'] : null, + 'created_at' => $assignment['created_at'] ?? null, + ]; + } + + private function publicDeploymentTarget(array $target): array + { + $deployContext = self::jsonDecode($target['deploy_context_json'] ?? null); + $targetWithContext = $target + ['deploy_context' => $deployContext]; + return [ + 'id' => (int)($target['id'] ?? 0), + 'channel_id' => (int)($target['channel_id'] ?? 0), + 'channel_slug' => (string)($target['channel_slug'] ?? ''), + 'channel_name' => (string)($target['channel_name'] ?? ''), + 'app' => (string)($target['app'] ?? ''), + 'coolify_instance_id' => isset($target['coolify_instance_id']) ? (int)$target['coolify_instance_id'] : null, + 'coolify_instance_label' => $target['coolify_instance_label'] ?? null, + 'coolify_service_uuid' => $target['coolify_service_uuid'] ?? null, + 'repository' => (string)($target['repository'] ?? ''), + 'branch' => (string)($target['branch'] ?? ''), + 'auto_deploy' => (bool)((int)($target['auto_deploy'] ?? 0)), + 'health_url' => $target['health_url'] ?? null, + 'deploy_context' => $deployContext, + 'endpoint' => $this->releaseDeploymentEndpoint($targetWithContext), + 'created_at' => $target['created_at'] ?? null, + 'updated_at' => $target['updated_at'] ?? null, + ]; + } + + private function publicServiceSet(array $serviceSet, bool $includeBundles = true): array + { + $dataServices = []; + foreach (self::STACK_DATA_KINDS as $kind) { + $dataServices[$kind] = $this->nullableCoolifyTarget( + $this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null) + ); + } + + $attachedBundles = $includeBundles ? $this->serviceSetBundles((int)($serviceSet['id'] ?? 0)) : []; + $serviceSetId = (int)($serviceSet['id'] ?? 0); + $metadata = self::jsonDecode($serviceSet['metadata_json'] ?? null); + $dataPolicy = $this->serviceSetDataPolicy(array_replace($serviceSet, ['metadata' => $metadata])); + + return [ + 'id' => $serviceSetId, + 'channel_id' => isset($serviceSet['channel_id']) ? (int)$serviceSet['channel_id'] : null, + 'channel_slug' => $serviceSet['channel_slug'] ?? null, + 'channel_name' => $serviceSet['channel_name'] ?? null, + 'name' => (string)($serviceSet['name'] ?? ''), + 'slug' => (string)($serviceSet['slug'] ?? ''), + 'mode' => (string)($serviceSet['mode'] ?? 'attach_existing'), + 'data_policy' => $dataPolicy, + 'data_service_mode' => $dataPolicy, + 'source_service_set_id' => isset($serviceSet['source_service_set_id']) ? (int)$serviceSet['source_service_set_id'] : null, + 'data_source_service_set_id' => $this->nullablePositiveInt($metadata['data_source_service_set_id'] ?? null), + 'data_source_channel_slug' => $metadata['data_source_channel_slug'] ?? null, + 'status' => (string)($serviceSet['status'] ?? 'unknown'), + 'active' => $serviceSetId > 0 && $this->serviceSetIsActive($serviceSetId), + 'targets' => [ + 'frontend' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null)), + 'api' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['api_target_id'] ?? null)), + ], + 'data_services' => $dataServices, + 'stack' => [ + 'frontend' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null)), + 'api' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['api_target_id'] ?? null)), + 'database' => $dataServices['database'], + 'redis' => $dataServices['redis'], + 'minio' => $dataServices['minio'], + ], + 'health' => self::jsonDecode($serviceSet['health_json'] ?? null), + 'metadata' => $metadata, + 'attached_bundle_count' => count($attachedBundles), + 'attached_bundles' => $attachedBundles, + 'created_at' => $serviceSet['created_at'] ?? null, + 'updated_at' => $serviceSet['updated_at'] ?? null, + ]; + } + + private function publicBundle(array $bundle, bool $includeServiceSet = true): array + { + $bundleId = (int)($bundle['id'] ?? 0); + $frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null); + $apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null); + $frontendDeploymentId = $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null); + $apiDeploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null); + $frontendVersion = $frontendVersionId !== null ? $this->publicVersion($this->getVersion($frontendVersionId)) : null; + $apiVersion = $apiVersionId !== null ? $this->publicVersion($this->getVersion($apiVersionId)) : null; + $frontendCommit = $this->versionGithubCommit($frontendVersion); + $apiCommit = $this->versionGithubCommit($apiVersion); + $active = $bundleId > 0 && $this->bundleIsActive($bundleId); + $status = (string)($bundle['status'] ?? 'draft'); + if ($status === 'promoted' && !$active) { + $status = 'superseded'; + } + + return [ + 'id' => $bundleId, + 'channel_id' => (int)($bundle['channel_id'] ?? 0), + 'channel_slug' => (string)($bundle['channel_slug'] ?? ''), + 'channel_name' => (string)($bundle['channel_name'] ?? ''), + 'service_set_id' => (int)($bundle['service_set_id'] ?? 0), + 'service_set_name' => $bundle['service_set_name'] ?? null, + 'service_set_slug' => $bundle['service_set_slug'] ?? null, + 'service_set' => $includeServiceSet ? $this->publicServiceSet($this->getServiceSet((int)$bundle['service_set_id']), false) : null, + 'version_label' => $bundle['version_label'] ?? null, + 'status' => $status, + 'active' => $active, + 'apps' => [ + 'frontend' => [ + 'version_id' => $frontendVersionId, + 'deployment_id' => $frontendDeploymentId, + 'repository' => $bundle['frontend_repository'] ?? null, + 'branch' => $bundle['frontend_branch'] ?? null, + 'commit_sha' => $bundle['frontend_commit_sha'] ?? null, + 'commit' => $frontendCommit, + 'commit_authored_at' => $frontendCommit['authored_at'] ?? null, + 'version' => $frontendVersion, + 'deployment' => $this->nullableDeployment($frontendDeploymentId), + ], + 'api' => [ + 'version_id' => $apiVersionId, + 'deployment_id' => $apiDeploymentId, + 'repository' => $bundle['api_repository'] ?? null, + 'branch' => $bundle['api_branch'] ?? null, + 'commit_sha' => $bundle['api_commit_sha'] ?? null, + 'commit' => $apiCommit, + 'commit_authored_at' => $apiCommit['authored_at'] ?? null, + 'version' => $apiVersion, + 'deployment' => $this->nullableDeployment($apiDeploymentId), + ], + ], + 'deployment_result' => self::jsonDecode($bundle['deployment_result_json'] ?? null), + 'metadata' => self::jsonDecode($bundle['metadata_json'] ?? null), + 'actor_user_id' => isset($bundle['actor_user_id']) ? (int)$bundle['actor_user_id'] : null, + 'deployed_at' => $bundle['deployed_at'] ?? null, + 'promoted_at' => $bundle['promoted_at'] ?? null, + 'created_at' => $bundle['created_at'] ?? null, + 'updated_at' => $bundle['updated_at'] ?? null, + ]; + } + + private function versionGithubCommit(?array $version): ?array + { + $metadata = is_array($version['metadata'] ?? null) ? $version['metadata'] : []; + $access = is_array($metadata['github_access'] ?? null) ? $metadata['github_access'] : []; + foreach (['commit', 'latest_commit'] as $key) { + $commit = is_array($access[$key] ?? null) ? $access[$key] : []; + if (trim((string)($commit['sha'] ?? '')) !== '') { + return $commit; + } + } + + return null; + } + + private function serviceSetBundles(int $serviceSetId): array + { + if ($serviceSetId <= 0 || !release_manager_schema_bootstrap::tablesExist()) { + return []; + } + + return array_map( + fn(array $row): array => $this->publicBundle($row, false), + $this->selectRows( + "SELECT b.*, c.slug AS channel_slug, c.name AS channel_name, s.name AS service_set_name, s.slug AS service_set_slug + FROM release_bundles b + INNER JOIN release_channels c ON c.id = b.channel_id + INNER JOIN release_service_sets s ON s.id = b.service_set_id + WHERE b.deleted_at IS NULL AND b.service_set_id = ? + ORDER BY b.created_at DESC, b.id DESC + LIMIT 10", + 'i', + [$serviceSetId] + ) + ); + } + + private function serviceSetIsActive(int $serviceSetId): bool + { + return $this->selectOne( + 'SELECT id FROM release_channel_versions WHERE service_set_id = ? AND active = 1 LIMIT 1', + 'i', + [$serviceSetId] + ) !== null; + } + + private function bundleIsActive(int $bundleId): bool + { + return $this->selectOne( + 'SELECT id FROM release_channel_versions WHERE bundle_id = ? AND active = 1 LIMIT 1', + 'i', + [$bundleId] + ) !== null; + } + + private function isolatedDeploymentTargetCanBeForgotten(?int $targetId, int $serviceSetId): bool + { + if ($targetId === null || $targetId <= 0) { + return false; + } + + $target = $this->nullableDeploymentTarget($targetId); + if ($target === null) { + return false; + } + + $context = is_array($target['deploy_context'] ?? null) ? $target['deploy_context'] : []; + if (!$this->toBool($context['isolated_stack'] ?? false)) { + return false; + } + if ($this->toBool($context['production_data_attached'] ?? false)) { + return false; + } + + return $this->selectOne( + "SELECT id + FROM release_service_sets + WHERE id <> ? AND deleted_at IS NULL AND (frontend_target_id = ? OR api_target_id = ?) + LIMIT 1", + 'iii', + [$serviceSetId, $targetId, $targetId] + ) === null; + } + + private function isolatedCoolifyTargetCanBeForgotten(?int $targetId, int $serviceSetId): bool + { + if ($targetId === null || $targetId <= 0 || !$this->tableExists('coolify_targets')) { + return false; + } + + $target = $this->nullableCoolifyTarget($targetId); + if ($target === null) { + return false; + } + + $options = is_array($target['options'] ?? null) ? $target['options'] : []; + if (!$this->toBool($options['isolated_stack'] ?? false)) { + return false; + } + if ($this->toBool($options['production_data_attached'] ?? false)) { + return false; + } + + return $this->selectOne( + "SELECT id + FROM release_service_sets + WHERE id <> ? AND deleted_at IS NULL + AND ( + database_coolify_target_id = ? + OR redis_coolify_target_id = ? + OR minio_coolify_target_id = ? + ) + LIMIT 1", + 'iiii', + [$serviceSetId, $targetId, $targetId, $targetId] + ) === null; + } + + private function nullableDeploymentTarget(?int $id): ?array + { + if ($id === null || $id <= 0) { + return null; + } + + try { + return $this->publicDeploymentTarget($this->getDeploymentTarget($id)); + } catch (Throwable) { + return null; + } + } + + private function nullableDeployment(?int $id): ?array + { + if ($id === null || $id <= 0) { + return null; + } + + try { + return $this->publicDeployment($this->getDeployment($id)); + } catch (Throwable) { + return null; + } + } + + private function nullableCoolifyTarget(?int $id): ?array + { + if ($id === null || $id <= 0 || !$this->tableExists('coolify_targets')) { + return null; + } + + $hasReplicationHosts = $this->tableExists('replication_hosts'); + $replicationColumns = $hasReplicationHosts + ? "h.id AS host_id, h.kind AS host_kind, h.label AS host_label, h.host AS host_host, + h.port AS host_port, h.role AS host_role, h.status AS host_status, + h.replication_source_id AS host_replication_source_id, + h.last_status_json AS host_last_status_json, h.last_checked_at AS host_last_checked_at" + : "NULL AS host_id, NULL AS host_kind, NULL AS host_label, NULL AS host_host, + NULL AS host_port, NULL AS host_role, NULL AS host_status, + NULL AS host_replication_source_id, + NULL AS host_last_status_json, NULL AS host_last_checked_at"; + $replicationJoin = $hasReplicationHosts ? 'LEFT JOIN replication_hosts h ON h.id = t.replication_host_id' : ''; + + $row = $this->selectOne( + "SELECT t.*, i.label AS instance_label, i.base_url AS instance_base_url, + $replicationColumns + FROM coolify_targets t + LEFT JOIN coolify_instances i ON i.id = t.instance_id + $replicationJoin + WHERE t.id = ? AND t.deleted_at IS NULL", + 'i', + [$id] + ); + if ($row === null) { + return null; + } + + $replication = !empty($row['host_id']) ? [ + 'id' => (int)$row['host_id'], + 'kind' => (string)($row['host_kind'] ?? $row['kind'] ?? ''), + 'label' => (string)($row['host_label'] ?? ''), + 'host' => $row['host_host'] ?? null, + 'port' => isset($row['host_port']) ? (int)$row['host_port'] : null, + 'role' => (string)($row['host_role'] ?? 'unknown'), + 'status' => (string)($row['host_status'] ?? 'unknown'), + 'source_host_id' => isset($row['host_replication_source_id']) ? (int)$row['host_replication_source_id'] : null, + 'last_status' => self::jsonDecode($row['host_last_status_json'] ?? null), + 'last_checked_at' => $row['host_last_checked_at'] ?? null, + ] : null; + $endpoint = $replication !== null && !empty($replication['host']) + ? $this->releaseEndpointFromParts( + 'auto', + 'resolved', + (string)$replication['host'], + isset($replication['port']) ? (int)$replication['port'] : null, + 'replication_host', + 'Endpoint resolved from the attached replication host.' + ) + : self::releasePendingEndpoint('auto', 'coolify_target', 'Automatic endpoint resolution is pending Coolify target metadata.'); + + return [ + 'id' => (int)($row['id'] ?? 0), + 'kind' => (string)($row['kind'] ?? ''), + 'label' => (string)($row['label'] ?? ''), + 'role' => (string)($row['role'] ?? ''), + 'instance_id' => isset($row['instance_id']) ? (int)$row['instance_id'] : null, + 'instance_label' => $row['instance_label'] ?? null, + 'resource_uuid' => $row['resource_uuid'] ?? null, + 'resource_name' => $row['resource_name'] ?? null, + 'deployment_status' => (string)($row['deployment_status'] ?? 'unknown'), + 'availability_state' => (string)($row['availability_state'] ?? 'unknown'), + 'last_reconcile_status' => $row['last_reconcile_status'] ?? null, + 'last_reconciled_at' => $row['last_reconciled_at'] ?? null, + 'endpoint' => $endpoint, + 'replication' => $replication, + 'options' => self::jsonDecode($row['options_json'] ?? null), + ]; + } + + private function publicDeployment(array $deployment): array + { + $status = (string)($deployment['status'] ?? 'unknown'); + $result = self::jsonDecode($deployment['result_json'] ?? null); + $failureSummary = is_array($result['failure_summary'] ?? null) ? $result['failure_summary'] : null; + $promotable = self::deploymentCanBePromoted($status); + + return [ + 'id' => (int)($deployment['id'] ?? 0), + 'channel_id' => (int)($deployment['channel_id'] ?? 0), + 'channel_slug' => (string)($deployment['channel_slug'] ?? ''), + 'channel_name' => (string)($deployment['channel_name'] ?? ''), + 'target_id' => isset($deployment['target_id']) ? (int)$deployment['target_id'] : null, + 'version_id' => isset($deployment['version_id']) ? (int)$deployment['version_id'] : null, + 'service_set_id' => isset($deployment['service_set_id']) ? (int)$deployment['service_set_id'] : null, + 'bundle_id' => isset($deployment['bundle_id']) ? (int)$deployment['bundle_id'] : null, + 'deployment_kind' => (string)($deployment['deployment_kind'] ?? 'single_app'), + 'version_label' => $deployment['version_label'] ?? null, + 'app' => (string)($deployment['app'] ?? ''), + 'active_channel_app_key' => $deployment['active_channel_app_key'] ?? null, + 'active_current' => trim((string)($deployment['active_channel_app_key'] ?? '')) !== '', + 'provider' => (string)($deployment['provider'] ?? 'coolify'), + 'repository' => $deployment['repository'] ?? null, + 'branch' => $deployment['branch'] ?? null, + 'commit_sha' => $deployment['commit_sha'] ?? null, + 'status' => $status, + 'deployment_url' => $deployment['deployment_url'] ?? $deployment['deployed_url'] ?? null, + 'actor_user_id' => isset($deployment['actor_user_id']) ? (int)$deployment['actor_user_id'] : null, + 'result' => $result, + 'failure_summary' => $failureSummary, + 'error_message' => $deployment['error_message'] ?? null, + 'promotable' => $promotable, + 'promotion_blocked_reason' => $promotable ? null : self::deploymentPromotionBlockedReason($deployment), + 'started_at' => $deployment['started_at'] ?? null, + 'completed_at' => $deployment['completed_at'] ?? null, + 'created_at' => $deployment['created_at'] ?? null, + 'updated_at' => $deployment['updated_at'] ?? null, + ]; + } + + private function publicTimelineEvent(array $event): array + { + return [ + 'id' => (int)($event['id'] ?? 0), + 'timeline_session_id' => isset($event['timeline_session_id']) ? (int)$event['timeline_session_id'] : null, + 'trace_id' => (string)($event['trace_id'] ?? ''), + 'event_type' => (string)($event['event_type'] ?? ''), + 'severity' => (string)($event['severity'] ?? 'info'), + 'module_key' => $event['module_key'] ?? null, + 'route_path' => $event['route_path'] ?? null, + 'component' => $event['component'] ?? null, + 'request_id' => $event['request_id'] ?? null, + 'occurred_at' => $event['occurred_at'] ?? null, + 'payload' => self::jsonDecode($event['payload_json'] ?? null), + 'principal_type' => $event['principal_type'] ?? null, + 'principal_id' => $event['principal_id'] ?? null, + 'customer_number' => isset($event['customer_number']) ? (int)$event['customer_number'] : null, + 'channel_slug' => $event['channel_slug'] ?? null, + ]; + } + + private function publicTimelineSession(array $session): array + { + $moduleKeys = array_filter(array_map('trim', explode(',', (string)($session['module_keys'] ?? '')))); + $principal = $this->timelinePrincipal($session); + + return [ + 'id' => (int)($session['id'] ?? 0), + 'trace_id' => (string)($session['trace_id'] ?? ''), + 'principal_type' => $session['principal_type'] ?? null, + 'principal_id' => $session['principal_id'] ?? null, + 'customer_number' => isset($session['customer_number']) ? (int)$session['customer_number'] : null, + 'user' => $principal, + 'channel_id' => isset($session['channel_id']) ? (int)$session['channel_id'] : null, + 'channel_slug' => $session['channel_slug'] ?? null, + 'release' => [ + 'frontend' => [ + 'version_label' => $session['frontend_version_label'] ?? null, + 'commit_sha' => $session['frontend_commit_sha'] ?? null, + ], + 'api' => [ + 'version_label' => $session['api_version_label'] ?? null, + 'commit_sha' => $session['api_commit_sha'] ?? null, + ], + ], + 'device' => [ + 'type' => $session['device_type'] ?? null, + 'browser_name' => $session['browser_name'] ?? null, + 'browser_version' => $session['browser_version'] ?? null, + 'os_name' => $session['os_name'] ?? null, + 'os_version' => $session['os_version'] ?? null, + 'viewport_width' => isset($session['viewport_width']) ? (int)$session['viewport_width'] : null, + 'viewport_height' => isset($session['viewport_height']) ? (int)$session['viewport_height'] : null, + 'device_pixel_ratio' => isset($session['device_pixel_ratio']) ? (float)$session['device_pixel_ratio'] : null, + 'user_agent' => $session['user_agent'] ?? null, + ], + 'last_route_path' => $session['last_route_path'] ?? null, + 'event_count' => isset($session['event_count']) ? (int)$session['event_count'] : 0, + 'error_count' => isset($session['error_count']) ? (int)$session['error_count'] : 0, + 'error_report_count' => isset($session['error_report_count']) ? (int)$session['error_report_count'] : 0, + 'module_keys' => array_values($moduleKeys), + 'first_event_at' => $session['first_event_at'] ?? null, + 'last_event_at' => $session['last_event_at'] ?? $session['last_seen_at'] ?? null, + 'created_at' => $session['created_at'] ?? null, + 'last_seen_at' => $session['last_seen_at'] ?? null, + ]; + } + + private function timelinePrincipal(array $session): array + { + $type = $session['principal_type'] ?? null; + $id = $session['principal_id'] ?? null; + $customerNumber = isset($session['customer_number']) ? (int)$session['customer_number'] : null; + $label = trim(implode(':', array_filter([(string)$type, (string)$id]))); + $name = null; + $email = null; + + if ($type === 'user' && is_numeric($id) && $this->tableExists('users')) { + $row = $this->selectOne( + 'SELECT id, customer_number, display_name, email FROM users WHERE id = ? LIMIT 1', + 'i', + [(int)$id] + ); + if ($row !== null) { + $name = $row['display_name'] ?? null; + $email = $row['email'] ?? null; + $customerNumber = isset($row['customer_number']) ? (int)$row['customer_number'] : $customerNumber; + } + } + + if ($type === 'subuser' && is_numeric($id) && $this->tableExists('subusers')) { + $row = $this->selectOne( + 'SELECT id, username, name, email FROM subusers WHERE id = ? LIMIT 1', + 'i', + [(int)$id] + ); + if ($row !== null) { + $name = $row['name'] ?? $row['username'] ?? null; + $email = $row['email'] ?? null; + } + } + + $displayLabel = trim((string)($name ?: $email ?: $label)); + if ($displayLabel === '') { + $displayLabel = $customerNumber !== null ? 'customer:' . $customerNumber : 'unknown'; + } + + return [ + 'type' => $type, + 'id' => $id, + 'customer_number' => $customerNumber, + 'name' => $name, + 'email' => $email, + 'label' => $displayLabel, + ]; + } + + private function timelineErrorReports(string $traceId): array + { + if (!$this->tableExists('error_reports')) { + return []; + } + + $rows = $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, request_error_count, vue_error_count, created_at, updated_at + FROM error_reports + WHERE release_trace_id = ? + ORDER BY created_at DESC, id DESC + LIMIT 25", + 's', + [$traceId] + ); + + return array_map(static fn(array $row): array => [ + 'id' => (int)($row['id'] ?? 0), + '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, + '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, + 'created_at' => $row['created_at'] ?? null, + 'updated_at' => $row['updated_at'] ?? null, + ], $rows); + } + + private function timelineReleaseContext(array $session, ?array $channel): array + { + $frontend = $this->timelineAppReleaseContext( + 'frontend', + $session['frontend_version_label'] ?? null, + $session['frontend_commit_sha'] ?? null + ); + $api = $this->timelineAppReleaseContext( + 'api', + $session['api_version_label'] ?? null, + $session['api_commit_sha'] ?? null + ); + + $bundle = $this->timelineBundleReference( + $this->nullablePositiveInt($frontend['version']['id'] ?? null), + $this->nullablePositiveInt($api['version']['id'] ?? null), + $this->nullablePositiveInt($frontend['deployment']['bundle_id'] ?? $api['deployment']['bundle_id'] ?? null) + ); + + return [ + 'channel' => $channel !== null ? $this->publicChannel($channel) : null, + 'frontend' => $frontend, + 'api' => $api, + 'bundle' => $bundle, + ]; + } + + private function timelineAppReleaseContext(string $app, mixed $versionLabel, mixed $commitSha): array + { + $versionLabel = $this->nullableString($versionLabel, 128); + $commitSha = $this->nullableString($commitSha, 128); + $context = [ + 'version_label' => $versionLabel, + 'commit_sha' => $commitSha, + 'version' => null, + 'deployment' => null, + ]; + + $where = ['app = ?']; + $types = 's'; + $params = [$app]; + if ($versionLabel !== null && $commitSha !== null) { + $where[] = '(version_label = ? OR commit_sha = ?)'; + $types .= 'ss'; + $params[] = $versionLabel; + $params[] = $commitSha; + } elseif ($versionLabel !== null) { + $where[] = 'version_label = ?'; + $types .= 's'; + $params[] = $versionLabel; + } elseif ($commitSha !== null) { + $where[] = 'commit_sha = ?'; + $types .= 's'; + $params[] = $commitSha; + } else { + return $context; + } + + $version = $this->selectOne( + "SELECT id, app, repository, branch, commit_sha, tag, version_label, build_url, + artifact_url, deployed_url, status, created_at, deployed_at + FROM release_versions + WHERE " . implode(' AND ', $where) . " + ORDER BY deployed_at DESC, id DESC + LIMIT 1", + $types, + $params + ); + if ($version === null) { + return $context; + } + + $context['version_label'] = $version['version_label'] ?? $versionLabel; + $context['commit_sha'] = $version['commit_sha'] ?? $commitSha; + $context['version'] = $this->publicTimelineVersionReference($version); + + $deployment = $this->selectOne( + "SELECT id, channel_id, target_id, version_id, service_set_id, bundle_id, + deployment_kind, app, provider, repository, branch, commit_sha, + status, deployment_url, started_at, completed_at, created_at + FROM release_deployments + WHERE version_id = ? + ORDER BY id DESC + LIMIT 1", + 'i', + [(int)$version['id']] + ); + if ($deployment !== null) { + $context['deployment'] = $this->publicTimelineDeploymentReference($deployment); + } + + return $context; + } + + private function timelineBundleReference(?int $frontendVersionId, ?int $apiVersionId, ?int $bundleId): ?array + { + if ($bundleId !== null) { + $bundle = $this->selectOne( + "SELECT id, channel_id, service_set_id, version_label, frontend_version_id, + api_version_id, frontend_deployment_id, api_deployment_id, + status, deployed_at, promoted_at, created_at + FROM release_bundles + WHERE id = ? AND deleted_at IS NULL + LIMIT 1", + 'i', + [$bundleId] + ); + return $bundle !== null ? $this->publicTimelineBundleReference($bundle) : null; + } + + $where = ['deleted_at IS NULL']; + $types = ''; + $params = []; + if ($frontendVersionId !== null && $apiVersionId !== null) { + $where[] = 'frontend_version_id = ?'; + $where[] = 'api_version_id = ?'; + $types .= 'ii'; + $params[] = $frontendVersionId; + $params[] = $apiVersionId; + } elseif ($frontendVersionId !== null) { + $where[] = 'frontend_version_id = ?'; + $types .= 'i'; + $params[] = $frontendVersionId; + } elseif ($apiVersionId !== null) { + $where[] = 'api_version_id = ?'; + $types .= 'i'; + $params[] = $apiVersionId; + } else { + return null; + } + + $bundle = $this->selectOne( + "SELECT id, channel_id, service_set_id, version_label, frontend_version_id, + api_version_id, frontend_deployment_id, api_deployment_id, + status, deployed_at, promoted_at, created_at + FROM release_bundles + WHERE " . implode(' AND ', $where) . " + ORDER BY id DESC + LIMIT 1", + $types, + $params + ); + + return $bundle !== null ? $this->publicTimelineBundleReference($bundle) : null; + } + + private function publicTimelineVersionReference(array $version): array + { + return [ + 'id' => (int)($version['id'] ?? 0), + 'app' => (string)($version['app'] ?? ''), + 'repository' => $version['repository'] ?? null, + 'branch' => $version['branch'] ?? null, + 'commit_sha' => $version['commit_sha'] ?? null, + 'tag' => $version['tag'] ?? null, + 'version_label' => $version['version_label'] ?? null, + 'build_url' => $version['build_url'] ?? null, + 'artifact_url' => $version['artifact_url'] ?? null, + 'deployed_url' => $version['deployed_url'] ?? null, + 'status' => (string)($version['status'] ?? ''), + 'created_at' => $version['created_at'] ?? null, + 'deployed_at' => $version['deployed_at'] ?? null, + ]; + } + + private function publicTimelineDeploymentReference(array $deployment): array + { + return [ + 'id' => (int)($deployment['id'] ?? 0), + 'channel_id' => isset($deployment['channel_id']) ? (int)$deployment['channel_id'] : null, + 'target_id' => isset($deployment['target_id']) ? (int)$deployment['target_id'] : null, + 'version_id' => isset($deployment['version_id']) ? (int)$deployment['version_id'] : null, + 'service_set_id' => isset($deployment['service_set_id']) ? (int)$deployment['service_set_id'] : null, + 'bundle_id' => isset($deployment['bundle_id']) ? (int)$deployment['bundle_id'] : null, + 'deployment_kind' => (string)($deployment['deployment_kind'] ?? ''), + 'app' => (string)($deployment['app'] ?? ''), + 'provider' => (string)($deployment['provider'] ?? ''), + 'repository' => $deployment['repository'] ?? null, + 'branch' => $deployment['branch'] ?? null, + 'commit_sha' => $deployment['commit_sha'] ?? null, + 'status' => (string)($deployment['status'] ?? ''), + 'deployment_url' => $deployment['deployment_url'] ?? null, + 'started_at' => $deployment['started_at'] ?? null, + 'completed_at' => $deployment['completed_at'] ?? null, + 'created_at' => $deployment['created_at'] ?? null, + ]; + } + + private function publicTimelineBundleReference(array $bundle): array + { + return [ + 'id' => (int)($bundle['id'] ?? 0), + 'channel_id' => isset($bundle['channel_id']) ? (int)$bundle['channel_id'] : null, + 'service_set_id' => isset($bundle['service_set_id']) ? (int)$bundle['service_set_id'] : null, + 'version_label' => $bundle['version_label'] ?? null, + 'frontend_version_id' => isset($bundle['frontend_version_id']) ? (int)$bundle['frontend_version_id'] : null, + 'api_version_id' => isset($bundle['api_version_id']) ? (int)$bundle['api_version_id'] : null, + 'frontend_deployment_id' => isset($bundle['frontend_deployment_id']) ? (int)$bundle['frontend_deployment_id'] : null, + 'api_deployment_id' => isset($bundle['api_deployment_id']) ? (int)$bundle['api_deployment_id'] : null, + 'status' => (string)($bundle['status'] ?? ''), + 'deployed_at' => $bundle['deployed_at'] ?? null, + 'promoted_at' => $bundle['promoted_at'] ?? null, + 'created_at' => $bundle['created_at'] ?? null, + ]; + } + + private function clearOtherDefaultChannels(int $channelId): void + { + $this->execute('UPDATE release_channels SET default_channel = 0 WHERE id <> ?', 'i', [$channelId]); + } + + private function normalizeApp(string $value): string + { + $app = strtolower(trim($value)); + if (!in_array($app, self::APPS, true)) { + throw new RuntimeException('Release app must be frontend or api.'); + } + return $app; + } + + private function normalizeCaptureLevel(string $value): string + { + $level = strtolower(trim($value)); + return in_array($level, self::CAPTURE_LEVELS, true) ? $level : 'metadata'; + } + + 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, max(1, $maxLength)); + } + + private function nullableIdentifier(mixed $value, int $maxLength): ?string + { + $identifier = self::safeIdentifier((string)($value ?? ''), $maxLength); + return $identifier === '' ? null : $identifier; + } + + private function nullableInt(mixed $value): ?int + { + if ($value === null || $value === '' || !is_numeric($value)) { + return null; + } + return (int)$value; + } + + private function nullableFloat(mixed $value): ?float + { + if ($value === null || $value === '' || !is_numeric($value)) { + return null; + } + return (float)$value; + } + + private function normalizeDateTime(mixed $value): ?string + { + if (!is_string($value) || trim($value) === '') { + return null; + } + $timestamp = strtotime($value); + return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp); + } + + private function nullablePositiveInt(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + $int = (int)$value; + return $int > 0 ? $int : null; + } + + private function toBool(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true); + } + + private function requestTraceId(): string + { + $context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null) + ? $GLOBALS['RELEASE_REQUEST_CONTEXT'] + : self::initializeRequestContext(); + return (string)($context['trace_id'] ?? ''); + } + + private function assignmentCacheKey(array $context): string + { + if (!empty($context['principal_type']) && !empty($context['principal_id'])) { + return 'release_manager:assignment:' . $context['principal_type'] . ':' . $context['principal_id']; + } + if (!empty($context['customer_number'])) { + return 'release_manager:assignment:customer:' . (int)$context['customer_number']; + } + return ''; + } + + private function cacheResolvedChannel(string $cacheKey, array $channel): void + { + if ($cacheKey === '' || !defined('redis')) { + return; + } + try { + redis->setEx($cacheKey, self::jsonEncode($channel), 60); + } catch (Throwable) { + } + } + + private function clearAssignmentCache(string $subjectType, string $subjectId): void + { + if (!defined('redis')) { + return; + } + try { + redis->delete('release_manager:assignment:' . $subjectType . ':' . $subjectId); + } catch (Throwable) { + } + } + + private function moduleConfigValue(string $module, string $variable, mixed $default = null): mixed + { + $row = $this->selectOne( + 'SELECT value FROM module_config WHERE module = ? AND variable = ? LIMIT 1', + 'ss', + [$module, $variable] + ); + return $row['value'] ?? $default; + } + + private function upsertModuleConfigValue(string $module, string $variable, string $value, string $type): void + { + $existing = $this->selectOne( + 'SELECT value FROM module_config WHERE module = ? AND variable = ? LIMIT 1', + 'ss', + [$module, $variable] + ); + + if ($existing === null) { + $this->execute( + 'INSERT INTO module_config (module, variable, value, type) VALUES (?, ?, ?, ?)', + 'ssss', + [$module, $variable, $value, $type] + ); + return; + } + + $this->execute( + 'UPDATE module_config SET value = ?, type = ? WHERE module = ? AND variable = ?', + 'ssss', + [$value, $type, $module, $variable] + ); + } + + private function audit(?int $channelId, ?int $deploymentId, string $action, ?int $actorUserId, string $severity, array $context): void + { + $this->execute( + "INSERT INTO release_audit_logs (channel_id, deployment_id, action, actor_user_id, severity, context_json) + VALUES (?, ?, ?, ?, ?, ?)", + 'iisiss', + [$channelId, $deploymentId, $action, $actorUserId, $severity, self::jsonEncode(self::redactPayload($context))] + ); + } + + 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 release manager query.'); + } + $stmt->bind_param($types, ...$params); + $stmt->execute(); + $result = $stmt->get_result(); + return $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + } + + private function tableExists(string $table): bool + { + $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table) ?? ''; + if ($table === '') { + return false; + } + + try { + return $this->selectOne( + 'SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ? LIMIT 1', + 's', + [$table] + ) !== null; + } catch (Throwable) { + return false; + } + } + + private function cleanupExpiredReplayData(): void + { + try { + $this->execute( + "UPDATE release_replay_targets + SET deleted_at = NOW(), enabled = 0 + WHERE deleted_at IS NULL AND expires_at IS NOT NULL AND expires_at <= NOW()" + ); + + $this->execute( + "DELETE e + FROM release_timeline_events e + INNER JOIN release_timeline_sessions s ON s.id = e.timeline_session_id + LEFT JOIN release_channels c ON c.id = s.channel_id + WHERE s.last_seen_at < DATE_SUB(NOW(), INTERVAL COALESCE(c.retention_days, 14) DAY)" + ); + + $this->execute( + "DELETE s + FROM release_timeline_sessions s + LEFT JOIN release_channels c ON c.id = s.channel_id + WHERE s.last_seen_at < DATE_SUB(NOW(), INTERVAL COALESCE(c.retention_days, 14) DAY)" + ); + } catch (Throwable) { + // Retention cleanup should never block release debugging reads. + } + } + + 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 release manager statement.'); + } + $stmt->bind_param($types, ...$params); + $stmt->execute(); + } + + private function insertId(): int + { + global $db; + return (int)$db->insert_id(); + } + + private static function headerValue(array $headers, string $name): string + { + foreach ($headers as $key => $value) { + if (strcasecmp((string)$key, $name) === 0) { + return trim((string)$value); + } + } + $serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); + return trim((string)($_SERVER[$serverKey] ?? '')); + } + + private static function requestHeaderValue(string $name): string + { + $headers = function_exists('getallheaders') ? getallheaders() : []; + return self::headerValue(is_array($headers) ? $headers : [], $name); + } + + private static function safeSlug(string $value): string + { + $slug = strtolower(trim($value)); + $slug = preg_replace('/[^a-z0-9_-]/', '-', $slug) ?? ''; + $slug = trim(preg_replace('/-+/', '-', $slug) ?? '', '-'); + return substr($slug, 0, 64); + } + + private static function safeIdentifier(string $value, int $maxLength): string + { + $value = trim($value); + $value = preg_replace('/[^a-zA-Z0-9_.:-]/', '', $value) ?? ''; + return substr($value, 0, max(1, $maxLength)); + } + + private static function isSensitiveKey(string $key): bool + { + return preg_match('/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn/i', $key) === 1; + } + + private static function jsonEncode(mixed $value): string + { + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Could not encode release manager JSON payload.'); + } + return $json; + } + + private static function jsonDecode(mixed $value): array + { + if (!is_string($value) || trim($value) === '') { + return []; + } + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : []; + } +} diff --git a/services/nginx/app/classes/release_manager_schema_bootstrap.php b/services/nginx/app/classes/release_manager_schema_bootstrap.php new file mode 100644 index 00000000..a3243adb --- /dev/null +++ b/services/nginx/app/classes/release_manager_schema_bootstrap.php @@ -0,0 +1,557 @@ +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)"); + } +} diff --git a/services/nginx/app/classes/releasemanager.php b/services/nginx/app/classes/releasemanager.php new file mode 100644 index 00000000..a4450dfe --- /dev/null +++ b/services/nginx/app/classes/releasemanager.php @@ -0,0 +1,19 @@ +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; + } + } +} diff --git a/services/nginx/app/classes/replica_failover_manager.php b/services/nginx/app/classes/replica_failover_manager.php new file mode 100644 index 00000000..5aaa03d8 --- /dev/null +++ b/services/nginx/app/classes/replica_failover_manager.php @@ -0,0 +1,521 @@ + 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 : []; + } +} diff --git a/services/nginx/app/classes/replication_bootstrap_config.php b/services/nginx/app/classes/replication_bootstrap_config.php new file mode 100644 index 00000000..bb754721 --- /dev/null +++ b/services/nginx/app/classes/replication_bootstrap_config.php @@ -0,0 +1,156 @@ + 1, + 'generated_at' => date('c'), + ], $snapshot); + + $json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Could not encode replication bootstrap snapshot.'); + } + + $tempPath = tempnam($dir, 'replication-bootstrap-'); + if ($tempPath === false) { + throw new RuntimeException('Could not create replication bootstrap snapshot temp file.'); + } + + try { + if (file_put_contents($tempPath, $json . PHP_EOL, LOCK_EX) === false) { + throw new RuntimeException('Could not write replication bootstrap snapshot.'); + } + + if (!rename($tempPath, $path)) { + throw new RuntimeException('Could not atomically replace replication bootstrap snapshot.'); + } + } finally { + if (is_file($tempPath)) { + @unlink($tempPath); + } + } + } + + public static function applyToGlobals(array $snapshot): void + { + try { + $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); + } + + 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()); + } + } + + public static function activeDatabaseConfigFromSnapshot(mixed $config): ?array + { + if (!is_array($config)) { + return null; + } + + $host = trim((string)($config['host'] ?? '')); + $database = trim((string)($config['database'] ?? '')); + $user = trim((string)($config['user'] ?? '')); + if ($host === '' || $database === '' || $user === '') { + return null; + } + + return [ + 'host' => $host, + 'user' => $user, + 'password' => replication_secret_box::decrypt($config['password_secret'] ?? ''), + 'database' => $database, + 'port' => (int)($config['port'] ?? 3306) ?: 3306, + 'ssl_mode' => (string)($config['ssl_mode'] ?? 'DISABLED'), + ]; + } + + public static function activeRedisConfigFromSnapshot(mixed $config): ?array + { + if (!is_array($config)) { + return null; + } + + $host = trim((string)($config['host'] ?? '')); + if ($host === '') { + return null; + } + + return [ + 'host' => $host, + 'user' => (string)($config['user'] ?? ''), + 'password' => replication_secret_box::decrypt($config['password_secret'] ?? ''), + 'database' => (int)($config['database'] ?? 0), + 'port' => (int)($config['port'] ?? 6379) ?: 6379, + ]; + } + + 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__); + return $root . DIRECTORY_SEPARATOR . 'storage'; + } +} diff --git a/services/nginx/app/classes/replication_manager.php b/services/nginx/app/classes/replication_manager.php new file mode 100644 index 00000000..e2aa189c --- /dev/null +++ b/services/nginx/app/classes/replication_manager.php @@ -0,0 +1,6522 @@ +ensureEnvironmentPrimaryRows(); + if ($refresh) { + $this->refreshStatuses(); + } + + $databaseHosts = $this->listHosts(self::KIND_DATABASE); + $redisHosts = $this->listHosts(self::KIND_REDIS); + $minioHosts = $this->listHosts(self::KIND_MINIO); + + return [ + 'generated_at' => date('c'), + 'database' => [ + 'primary' => $this->publicHost($this->primaryHost(self::KIND_DATABASE)), + 'hosts' => array_map(fn(array $host): array => $this->publicHost($host), $databaseHosts), + 'replication' => $this->buildReplicationSummary(self::KIND_DATABASE, $databaseHosts), + ], + 'redis' => [ + 'primary' => $this->publicHost($this->primaryHost(self::KIND_REDIS)), + 'hosts' => array_map(fn(array $host): array => $this->publicHost($host), $redisHosts), + 'replication' => $this->buildReplicationSummary(self::KIND_REDIS, $redisHosts), + ], + 'minio' => [ + 'primary' => $this->publicHost($this->primaryHost(self::KIND_MINIO)), + 'hosts' => array_map(fn(array $host): array => $this->publicHost($host), $minioHosts), + 'replication' => $this->buildReplicationSummary(self::KIND_MINIO, $minioHosts), + ], + 'write_freeze' => application_write_freeze::state(), + ]; + } + + public function dependencyReplication(string $kind): array + { + $kind = self::normalizeKind($kind); + $this->ensureEnvironmentPrimaryRows(); + return $this->buildReplicationSummary($kind, $this->listHosts($kind)); + } + + public function addHost(string $kind, array $input, ?int $actorUserId = null): array + { + $kind = self::normalizeKind($kind); + $host = $this->normalizeHostInput($kind, $input); + + $this->execute( + "INSERT INTO replication_hosts ( + kind, label, host, port, database_name, database_index, username, password_secret, + admin_username, admin_password_secret, replication_username, replication_password_secret, + role, status, ssl_mode, options_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'replica', 'unknown', ?, ?)", + 'sssissssssssss', + [ + $kind, + $host['label'], + $host['host'], + $host['port'], + $host['database_name'], + $host['database_index'], + $host['username'], + $host['password_secret'], + $host['admin_username'], + $host['admin_password_secret'], + $host['replication_username'], + $host['replication_password_secret'], + $host['ssl_mode'], + self::jsonEncode($host['options']), + ] + ); + + $id = $this->insertId(); + $this->audit($kind, $id, 'host_added', $actorUserId, 'info', [ + 'label' => $host['label'], + 'host' => $host['host'], + 'port' => $host['port'], + ]); + $this->writeBootstrapSnapshot(); + + return $this->publicHost($this->getHost($kind, $id)); + } + + public function testHost(string $kind, int $id, ?int $actorUserId = null): array + { + $kind = self::normalizeKind($kind); + $host = $this->getHost($kind, $id); + + $status = match ($kind) { + self::KIND_DATABASE => $this->testDatabaseHost($host), + self::KIND_REDIS => $this->testRedisHost($host), + self::KIND_MINIO => $this->testMinioHost($host), + }; + + $this->storeStatus($host, $status); + $this->writeBootstrapSnapshot(); + $this->audit($kind, $id, 'host_tested', $actorUserId, $status['blockers'] === [] ? 'info' : 'warning', [ + 'status' => $status['status'], + 'replication_percent' => $status['replication_percent'], + 'blockers' => $status['blockers'], + ]); + + return [ + 'host' => $this->publicHost($this->getHost($kind, $id)), + 'status' => $status, + ]; + } + + public function testCredentials(string $kind, array $input): array + { + $kind = self::normalizeKind($kind); + $host = $this->transientHost($kind, $input); + $options = $this->decodeOptions($host); + if ($kind === self::KIND_DATABASE && !empty($options['allow_preseeded_replica'])) { + $host['connect_without_database'] = true; + } + $status = match ($kind) { + self::KIND_DATABASE => $this->testDatabaseHost($host), + self::KIND_REDIS => $this->testRedisHost($host), + self::KIND_MINIO => $this->testMinioHost(array_merge($host, [ + 'test_connectivity_only' => true, + 'skip_storage_scan' => true, + ])), + }; + + if ($kind === self::KIND_DATABASE + && ($host['role'] ?? '') !== 'primary' + && !empty($options['allow_preseeded_replica']) + && $status['status'] !== 'down') { + try { + $target = $this->databaseConnection($host, true); + try { + $seedBlockers = $this->databaseReplicaSeedBlockers($this->primaryHost(self::KIND_DATABASE), $host, $target); + } finally { + $target->close(); + } + } catch (Throwable $throwable) { + $seedBlockers = [$throwable->getMessage()]; + } + + if ($seedBlockers !== []) { + $status['blockers'] = array_values(array_unique(array_merge($status['blockers'], $seedBlockers))); + $status['status'] = 'degraded'; + if ((float)$status['replication_percent'] >= 100.0) { + $status['replication_percent'] = 99.99; + } + } + } + + return [ + 'ok' => $status['status'] === 'ok', + 'host' => $this->publicHost($host), + 'status' => $status, + ]; + } + + public function provisionHost( + string $kind, + int $id, + ?int $actorUserId = null, + bool $deferCoolifyManagedMinio = false + ): array + { + $kind = self::normalizeKind($kind); + $host = $this->getHost($kind, $id); + $operationId = $this->activeOperationId($kind, $id, 'provision') + ?? $this->startOperation($kind, $id, 'provision', $actorUserId); + + if ($deferCoolifyManagedMinio && $this->shouldDeferCoolifyManagedMinioProvision($kind, $host)) { + return $this->deferCoolifyManagedMinioProvision($host, $operationId); + } + + try { + $result = match ($kind) { + self::KIND_DATABASE => $this->provisionDatabaseHost($host, $operationId), + self::KIND_REDIS => $this->provisionRedisHost($host, $operationId), + self::KIND_MINIO => $this->provisionMinioHost($host, $operationId), + }; + + if (($result['operation']['status'] ?? null) === 'running') { + $this->audit($kind, $id, 'host_provision_progress', $actorUserId, 'info', $result); + return $result; + } + + $status = $result['ok'] ? 'completed' : 'blocked'; + $this->finishOperation($operationId, $status, (float)($result['replication_percent'] ?? 0), $result['message'] ?? null, $result['blockers'] ?? []); + $this->audit($kind, $id, 'host_provisioned', $actorUserId, $result['ok'] ? 'info' : 'warning', $result); + return $result; + } catch (Throwable $throwable) { + $this->finishOperation($operationId, 'failed', 0, null, [$throwable->getMessage()]); + $this->audit($kind, $id, 'host_provision_failed', $actorUserId, 'error', ['error' => $throwable->getMessage()]); + throw $throwable; + } + } + + private function shouldDeferCoolifyManagedMinioProvision(string $kind, array $host): bool + { + if ($kind !== self::KIND_MINIO) { + return false; + } + + $options = $this->decodeOptions($host); + return (string)($options['deployment_provider'] ?? '') === 'coolify' + || isset($options['coolify_target_id']) + || isset($options['coolify_instance_id']); + } + + private function deferCoolifyManagedMinioProvision(array $host, int $operationId): array + { + $lastStatus = self::sanitizePublicLastStatus($host, self::jsonDecode($host['last_status_json'] ?? null)); + $progress = 45.0; + if (is_array($lastStatus) && is_numeric($lastStatus['replication_percent'] ?? null)) { + $progress = max($progress, self::minioIncompleteProgress((float)$lastStatus['replication_percent'])); + } + + $message = 'MinIO provisioning was queued for Coolify background maintenance.'; + $this->updateOperationProgress($operationId, $progress, $message, [ + 'phase' => 'coolify_deferred', + 'queued_at' => date('c'), + ]); + $this->execute( + "UPDATE replication_hosts SET status = 'provisioning' WHERE id = ? AND kind = ?", + 'is', + [(int)$host['id'], self::KIND_MINIO] + ); + + $status = [ + 'status' => 'provisioning', + 'replication_percent' => $progress, + 'lag_seconds' => null, + 'blockers' => [self::MINIO_REPLICA_NOT_CAUGHT_UP_BLOCKER], + 'raw' => [ + 'progress_source' => 'coolify_deferred', + 'previous_status' => is_array($lastStatus) ? [ + 'status' => $lastStatus['status'] ?? null, + 'replication_percent' => $lastStatus['replication_percent'] ?? null, + 'checked_at' => $lastStatus['checked_at'] ?? null, + ] : null, + ], + 'checked_at' => date('c'), + ]; + $this->storeStatus($this->getHost(self::KIND_MINIO, (int)$host['id']), $status); + + return [ + 'ok' => true, + 'message' => $message, + 'blockers' => $status['blockers'], + 'replication_percent' => $progress, + 'operation' => [ + 'id' => $operationId, + 'status' => 'running', + 'progress_percent' => $progress, + 'message' => $message, + ], + 'host' => $this->publicHost($this->getHost(self::KIND_MINIO, (int)$host['id'])), + ]; + } + + public function promoteHost(string $kind, int $id, ?int $actorUserId = null): array + { + $kind = self::normalizeKind($kind); + $host = $this->getHost($kind, $id); + if (($host['role'] ?? '') === 'primary') { + return [ + 'ok' => true, + 'message' => 'Host is already primary.', + 'host' => $this->publicHost($host), + 'blockers' => [], + ]; + } + + $operationId = $this->startOperation($kind, $id, 'promote', $actorUserId); + $owner = 'replication-promote-' . $kind . '-' . $id . '-' . bin2hex(random_bytes(4)); + $lockHandle = $this->acquirePromotionLock(); + + try { + application_write_freeze::freeze('Replication promotion in progress.', $owner, 600); + $result = match ($kind) { + self::KIND_DATABASE => $this->promoteDatabaseHost($host), + self::KIND_REDIS => $this->promoteRedisHost($host), + self::KIND_MINIO => $this->promoteMinioHost($host), + }; + + $this->finishOperation($operationId, 'completed', 100, $result['message'] ?? null, []); + $this->audit($kind, $id, 'host_promoted', $actorUserId, 'critical', $result); + return $result; + } catch (Throwable $throwable) { + $this->finishOperation($operationId, 'failed', 0, null, [$throwable->getMessage()]); + $this->audit($kind, $id, 'host_promotion_failed', $actorUserId, 'error', ['error' => $throwable->getMessage()]); + throw $throwable; + } finally { + application_write_freeze::unfreeze($owner); + $this->releasePromotionLock($lockHandle); + } + } + + public function runAutomaticFailoverMonitor(?int $actorUserId = null): array + { + $this->ensureEnvironmentPrimaryRows(); + $config = $this->failoverConfigForSnapshot(); + $results = []; + + foreach ([self::KIND_DATABASE, self::KIND_REDIS, self::KIND_MINIO] as $kind) { + $results[$kind] = $this->runAutomaticFailoverForKind($kind, $config, $actorUserId); + } + + try { + $this->refreshStatuses(); + } catch (Throwable $throwable) { + $this->audit(self::KIND_DATABASE, null, 'automatic_failover_status_refresh_failed', $actorUserId, 'warning', [ + 'error' => $throwable->getMessage(), + ]); + } + + $this->writeBootstrapSnapshot(); + + return [ + 'ok' => true, + 'config' => $config, + 'results' => $results, + ]; + } + + public function syncStartupFailoversFromSnapshot(?int $actorUserId = null): array + { + $snapshot = replication_bootstrap_config::loadSnapshot(); + $pending = is_array($snapshot['pending_failovers'] ?? null) ? $snapshot['pending_failovers'] : []; + $synced = []; + + foreach ($pending as $entry) { + if (!is_array($entry)) { + continue; + } + + try { + $kind = self::normalizeKind((string)($entry['kind'] ?? '')); + $hostId = (int)($entry['host_id'] ?? 0); + if ($hostId <= 0) { + continue; + } + + $currentPrimary = $this->primaryHost($kind); + if ($currentPrimary !== null && (int)$currentPrimary['id'] !== $hostId) { + $this->switchPrimary($kind, $hostId, (int)$currentPrimary['id']); + } + + $this->audit($kind, $hostId, 'startup_failover_synced', $actorUserId, 'critical', $entry); + $synced[] = [ + 'kind' => $kind, + 'host_id' => $hostId, + ]; + } catch (Throwable $throwable) { + $this->audit((string)($entry['kind'] ?? self::KIND_DATABASE), null, 'startup_failover_sync_failed', $actorUserId, 'error', [ + 'entry' => $entry, + 'error' => $throwable->getMessage(), + ]); + } + } + + if ($pending !== []) { + $snapshot['pending_failovers'] = []; + replication_bootstrap_config::writeSnapshot($snapshot); + $this->writeBootstrapSnapshot(); + } + + return $synced; + } + + public function removeHost(string $kind, int $id, ?int $actorUserId = null, bool $removeLinkedCoolifyTargets = true): array + { + $kind = self::normalizeKind($kind); + $host = $this->getHost($kind, $id); + $canRemove = self::replicationHostCanBeRemoved($host); + if (!$canRemove && class_exists(coolify_manager::class)) { + $canRemove = coolify_manager::replicationHostCanBeRemoved($host); + } + if (!$canRemove) { + if (($host['role'] ?? '') === 'primary') { + throw new RuntimeException('Primary hosts cannot be removed. Promote a healthy replica first.'); + } + throw new RuntimeException('Only inactive prior hosts or unhealthy replicas can be removed.'); + } + $this->execute( + "UPDATE replication_hosts SET deleted_at = NOW(), status = 'removed' WHERE id = ? AND kind = ?", + 'is', + [$id, $kind] + ); + $this->audit($kind, $id, 'host_removed', $actorUserId, 'warning', [ + 'label' => $host['label'] ?? '', + 'host' => $host['host'] ?? '', + ]); + if ($removeLinkedCoolifyTargets && class_exists(coolify_manager::class)) { + coolify_manager::markTargetsRemovedForReplicationHost($id, $actorUserId); + } + $this->writeBootstrapSnapshot(); + + return [ + 'ok' => true, + 'message' => 'Replication host removed.', + 'id' => $id, + 'kind' => $kind, + ]; + } + + public function renameHost(string $kind, int $id, array $input, ?int $actorUserId = null): array + { + $kind = self::normalizeKind($kind); + $host = $this->getHost($kind, $id); + $label = trim((string)($input['label'] ?? $input['name'] ?? '')); + if ($label === '') { + throw new RuntimeException('Replication host label is required.'); + } + if (mb_strlen($label) > 128) { + throw new RuntimeException('Replication host label must be 128 characters or fewer.'); + } + + $oldLabel = (string)($host['label'] ?? ''); + if ($label !== $oldLabel) { + $this->execute( + "UPDATE replication_hosts SET label = ? WHERE id = ? AND kind = ? AND deleted_at IS NULL", + 'sis', + [$label, $id, $kind] + ); + if (class_exists(coolify_manager::class)) { + coolify_manager::syncLabelForReplicationHost($id, $label); + } + $this->audit($kind, $id, 'host_renamed', $actorUserId, 'info', [ + 'old_label' => $oldLabel, + 'new_label' => $label, + ]); + $this->writeBootstrapSnapshot(); + } + + return $this->publicHost($this->getHost($kind, $id)); + } + + public static function replicationHostCanBeRemoved(array $host): bool + { + $role = (string)($host['role'] ?? ''); + if ($role === 'primary') { + return false; + } + if ($role === 'inactive') { + return true; + } + + $status = (string)($host['status'] ?? 'unknown'); + return $role === 'replica' && in_array($status, ['degraded', 'down', 'unknown', 'not_configured'], true); + } + + public static function composeTemplate(array $input): array + { + $kind = self::normalizeKind((string)($input['kind'] ?? self::KIND_DATABASE)); + $role = self::normalizeComposeRole((string)($input['role'] ?? 'replica')); + + return match ($kind) { + self::KIND_DATABASE => self::databaseComposeTemplate($input, $role), + self::KIND_REDIS => self::redisComposeTemplate($input, $role), + self::KIND_MINIO => self::minioComposeTemplate($input, $role), + }; + } + + public static function normalizeKind(string $kind): string + { + $kind = strtolower(trim($kind)); + if (in_array($kind, ['database', 'databases', 'mysql', 'db'], true)) { + return self::KIND_DATABASE; + } + if ($kind === self::KIND_REDIS) { + return self::KIND_REDIS; + } + if (in_array($kind, ['minio', 's3', 'object-storage', 'object_storage'], true)) { + return self::KIND_MINIO; + } + throw new RuntimeException('Unsupported replication kind.'); + } + + private static function databaseComposeTemplate(array $input, string $role): array + { + $serviceName = self::composeIdentifier($input['service_name'] ?? null, 'mariadb-' . $role); + $volumeName = self::composeIdentifier($input['volume_name'] ?? null, $serviceName . '-data'); + $database = self::composeScalar($input['database'] ?? null, 'nnks_db'); + $username = self::composeScalar($input['username'] ?? null, 'nnks_db_user'); + $image = self::composeImage($input['image'] ?? null, 'mariadb:11'); + $hostPort = self::boundedInt($input['host_port'] ?? null, $role === 'primary' ? 3306 : 3307, 1, 65535); + $serverId = self::boundedInt($input['server_id'] ?? null, $role === 'primary' ? 1 : 2, 1, 4294967295); + $rootPassword = self::composePassword($input['admin_password'] ?? null); + $applicationPassword = self::composePassword($input['password'] ?? null); + $replicationUsername = self::composeScalar($input['replication_username'] ?? null, 'replication'); + $replicationPassword = self::composePassword($input['replication_password'] ?? null); + $primaryHost = self::composeScalar($input['primary_host'] ?? null, ''); + $primaryPort = self::boundedInt($input['primary_port'] ?? null, 3306, 1, 65535); + $primaryAdminUsername = self::composeScalar($input['primary_admin_username'] ?? null, 'root'); + $primaryAdminPassword = trim((string)($input['primary_admin_password'] ?? '')); + + $command = [ + 'mariadbd', + '--server-id=' . $serverId, + '--log-bin=/var/lib/mysql/mariadb-bin', + '--binlog-format=ROW', + '--gtid-strict-mode=ON', + '--expire-logs-days=7', + ]; + if ($role === 'replica') { + $command[] = '--read-only=ON'; + foreach (self::MARIADB_SCHEMA_ONLY_TABLES as $tableName) { + $command[] = '--replicate-ignore-table=' . $database . '.' . $tableName; + } + } + + $lines = [ + 'services:', + ' ' . $serviceName . ':', + ' image: ' . self::yamlQuote($image), + ' restart: unless-stopped', + ' environment:', + ' MARIADB_ROOT_PASSWORD: "${MARIADB_ROOT_PASSWORD:?set MARIADB_ROOT_PASSWORD}"', + ' MARIADB_DATABASE: ' . self::yamlQuote($database), + ' MARIADB_USER: ' . self::yamlQuote($username), + ' MARIADB_PASSWORD: "${MARIADB_PASSWORD:?set MARIADB_PASSWORD}"', + ' command:', + ]; + + foreach ($command as $argument) { + $lines[] = ' - ' . self::yamlQuote($argument); + } + + $lines = array_merge($lines, [ + ' volumes:', + ' - ' . $volumeName . ':/var/lib/mysql', + ' ports:', + ' - ' . self::yamlQuote($hostPort . ':3306'), + ' healthcheck:', + ' test:', + ' - "CMD-SHELL"', + ' - "mariadb-admin ping -h 127.0.0.1 -uroot -p$${MARIADB_ROOT_PASSWORD} --silent"', + ' interval: 10s', + ' timeout: 5s', + ' retries: 12', + ]); + + if ($role === 'replica') { + $seedServiceName = self::composeIdentifier($serviceName . '-seed', 'mariadb-replica-seed'); + $seedScript = [ + 'marker="/var/lib/mysql/.truckwash-replica-seeded"', + 'if [ -f "$${marker}" ]; then', + ' echo "Replica already seeded."', + ' exit 0', + 'fi', + 'echo "Waiting for local replica..."', + 'until mariadb-admin ping -h ' . self::shellArg($serviceName) . ' -uroot -p"$${MARIADB_ROOT_PASSWORD}" --silent; do sleep 2; done', + 'echo "Importing seed from primary..."', + 'mariadb-dump --host="$${MARIADB_PRIMARY_HOST}" --port="$${MARIADB_PRIMARY_PORT}" --user="$${MARIADB_PRIMARY_ADMIN_USER}" --password="$${MARIADB_PRIMARY_ADMIN_PASSWORD}" --single-transaction --quick --routines --triggers --events --gtid --master-data=2 ' . self::mariaDbSchemaOnlyDumpIgnoreArgs('$${MARIADB_SEED_DATABASE}') . ' --databases "$${MARIADB_SEED_DATABASE}" | mariadb --host=' . self::shellArg($serviceName) . ' --user=root --password="$${MARIADB_ROOT_PASSWORD}"', + 'for table in ' . implode(' ', self::MARIADB_SCHEMA_ONLY_TABLES) . '; do', + ' mariadb-dump --host="$${MARIADB_PRIMARY_HOST}" --port="$${MARIADB_PRIMARY_PORT}" --user="$${MARIADB_PRIMARY_ADMIN_USER}" --password="$${MARIADB_PRIMARY_ADMIN_PASSWORD}" --single-transaction --quick --no-data "$${MARIADB_SEED_DATABASE}" "$${table}" | mariadb --host=' . self::shellArg($serviceName) . ' --user=root --password="$${MARIADB_ROOT_PASSWORD}" "$${MARIADB_SEED_DATABASE}" || true', + 'done', + 'touch "$${marker}"', + 'echo "Replica seed completed."', + ]; + + $lines = array_merge($lines, [ + ' ' . $seedServiceName . ':', + ' image: ' . self::yamlQuote($image), + ' restart: "no"', + ' depends_on:', + ' ' . $serviceName . ':', + ' condition: service_healthy', + ' environment:', + ' MARIADB_ROOT_PASSWORD: "${MARIADB_ROOT_PASSWORD:?set MARIADB_ROOT_PASSWORD}"', + ' MARIADB_PRIMARY_HOST: "${MARIADB_PRIMARY_HOST:?set MARIADB_PRIMARY_HOST}"', + ' MARIADB_PRIMARY_PORT: "${MARIADB_PRIMARY_PORT:-3306}"', + ' MARIADB_PRIMARY_ADMIN_USER: "${MARIADB_PRIMARY_ADMIN_USER:-root}"', + ' MARIADB_PRIMARY_ADMIN_PASSWORD: "${MARIADB_PRIMARY_ADMIN_PASSWORD:?set MARIADB_PRIMARY_ADMIN_PASSWORD}"', + ' MARIADB_SEED_DATABASE: ' . self::yamlQuote($database), + ' volumes:', + ' - ' . $volumeName . ':/var/lib/mysql', + ' entrypoint:', + ' - /bin/sh', + ' - -ec', + ' - |', + ]); + foreach ($seedScript as $scriptLine) { + $lines[] = ' ' . $scriptLine; + } + } + + $lines = array_merge($lines, [ + 'volumes:', + ' ' . $volumeName . ':', + ]); + + $steps = [ + 'Deploy this compose file as a normal Docker Compose or Coolify compose service.', + 'Keep server-id unique across the MariaDB primary and every replica.', + 'Create or store a replication user on the primary with REPLICATION SLAVE privileges.', + ]; + if ($role === 'replica') { + $steps[] = 'Fill MARIADB_PRIMARY_ADMIN_PASSWORD in the generated .env file.'; + $steps[] = 'Deploy the compose file and wait for the seed service to complete successfully.'; + $steps[] = 'Test the connection, then save and provision the replica.'; + } else { + $steps[] = 'Add the primary credentials in the superuser UI after the service is reachable.'; + } + + $seedCommand = implode(' ', [ + 'mariadb-dump', + '--host=' . self::shellArg($primaryHost), + '--port=' . $primaryPort, + '--user=', + '--password', + '--single-transaction', + '--quick', + '--routines', + '--triggers', + '--events', + '--gtid', + '--master-data=2', + ...self::mariaDbSchemaOnlySeedCommandIgnoreArgs($database), + '--databases', + self::shellArg($database), + '|', + 'mariadb', + '--host=', + '--port=' . $hostPort, + '--user=root', + '--password', + ]); + $envLines = [ + 'MARIADB_ROOT_PASSWORD=' . $rootPassword, + 'MARIADB_PASSWORD=' . $applicationPassword, + ]; + if ($role === 'replica') { + $envLines[] = 'MARIADB_PRIMARY_HOST=' . $primaryHost; + $envLines[] = 'MARIADB_PRIMARY_PORT=' . $primaryPort; + $envLines[] = 'MARIADB_PRIMARY_ADMIN_USER=' . $primaryAdminUsername; + $envLines[] = 'MARIADB_PRIMARY_ADMIN_PASSWORD=' . $primaryAdminPassword; + } + + return [ + 'kind' => self::KIND_DATABASE, + 'engine' => 'mariadb', + 'role' => $role, + 'service_name' => $serviceName, + 'host_port' => $hostPort, + 'server_id' => $serverId, + 'compose' => implode("\n", $lines) . "\n", + 'env' => implode("\n", $envLines) . "\n", + 'seed_command' => $role === 'replica' ? $seedCommand : '', + 'credentials' => [ + 'label' => $serviceName, + 'host' => '', + 'port' => $hostPort, + 'database' => $database, + 'username' => $username, + 'password' => $applicationPassword, + 'admin_username' => 'root', + 'admin_password' => $rootPassword, + 'replication_username' => $replicationUsername, + 'replication_password' => $replicationPassword, + 'ssl_mode' => 'DISABLED', + 'allow_preseeded_replica' => $role === 'replica', + ], + 'steps' => $steps, + ]; + } + + private static function redisComposeTemplate(array $input, string $role): array + { + $serviceName = self::composeIdentifier($input['service_name'] ?? null, 'redis-' . $role); + $volumeName = self::composeIdentifier($input['volume_name'] ?? null, $serviceName . '-data'); + $image = self::composeImage($input['image'] ?? null, 'redis:7'); + $hostPort = self::boundedInt($input['host_port'] ?? null, $role === 'primary' ? 6379 : 6380, 1, 65535); + $primaryHost = self::composeScalar($input['primary_host'] ?? null, 'redis-primary'); + $primaryPort = self::boundedInt($input['primary_port'] ?? null, 6379, 1, 65535); + $redisPassword = self::composePassword($input['password'] ?? null); + $primaryPassword = self::composeScalar($input['primary_password'] ?? null, ''); + $primaryUsername = self::composeScalar($input['primary_username'] ?? null, ''); + + $script = [ + 'if [ ! -f /data/redis.conf ]; then', + ' {', + ' echo "appendonly yes"', + ' echo "requirepass $$REDIS_PASSWORD"', + ]; + if ($role === 'replica') { + $script[] = ' echo "replicaof $$REDIS_PRIMARY_HOST $${REDIS_PRIMARY_PORT:-6379}"'; + $script[] = ' echo "masterauth $$REDIS_PRIMARY_PASSWORD"'; + $script[] = ' if [ -n "$${REDIS_PRIMARY_USERNAME:-}" ] && [ "$${REDIS_PRIMARY_USERNAME}" != "default" ]; then'; + $script[] = ' echo "masteruser $$REDIS_PRIMARY_USERNAME"'; + $script[] = ' fi'; + } + $script = array_merge($script, [ + ' } > /data/redis.conf', + 'fi', + 'exec redis-server /data/redis.conf', + ]); + + $lines = [ + 'services:', + ' ' . $serviceName . ':', + ' image: ' . self::yamlQuote($image), + ' restart: unless-stopped', + ' environment:', + ' REDIS_PASSWORD: "${REDIS_PASSWORD:?set REDIS_PASSWORD}"', + ]; + if ($role === 'replica') { + $lines[] = ' REDIS_PRIMARY_HOST: "${REDIS_PRIMARY_HOST:?set REDIS_PRIMARY_HOST}"'; + $lines[] = ' REDIS_PRIMARY_PORT: "${REDIS_PRIMARY_PORT:-6379}"'; + $lines[] = ' REDIS_PRIMARY_PASSWORD: "${REDIS_PRIMARY_PASSWORD:?set REDIS_PRIMARY_PASSWORD}"'; + $lines[] = ' REDIS_PRIMARY_USERNAME: "${REDIS_PRIMARY_USERNAME:-}"'; + } + $lines[] = ' command:'; + $lines[] = ' - /bin/sh'; + $lines[] = ' - -ec'; + $lines[] = ' - |'; + foreach ($script as $scriptLine) { + $lines[] = ' ' . $scriptLine; + } + + $lines = array_merge($lines, [ + ' volumes:', + ' - ' . $volumeName . ':/data', + ' ports:', + ' - ' . self::yamlQuote($hostPort . ':6379'), + ' healthcheck:', + ' test:', + ' - "CMD-SHELL"', + ' - "redis-cli --no-auth-warning -a \"$${REDIS_PASSWORD}\" ping | grep PONG"', + ' interval: 10s', + ' timeout: 5s', + ' retries: 12', + 'volumes:', + ' ' . $volumeName . ':', + ]); + + $steps = [ + 'Deploy this compose file as a normal Docker Compose or Coolify compose service.', + 'Add the Redis credentials in the superuser UI after the service is reachable.', + ]; + if ($role === 'replica') { + $steps[] = 'Use the current Redis primary host and password for REDIS_PRIMARY_HOST and REDIS_PRIMARY_PASSWORD, then run Test in the superuser UI.'; + } + + $envLines = [ + 'REDIS_PASSWORD=' . $redisPassword, + ]; + if ($role === 'replica') { + $envLines[] = 'REDIS_PRIMARY_HOST=' . $primaryHost; + $envLines[] = 'REDIS_PRIMARY_PORT=' . $primaryPort; + $envLines[] = 'REDIS_PRIMARY_PASSWORD=' . $primaryPassword; + $envLines[] = 'REDIS_PRIMARY_USERNAME=' . $primaryUsername; + } + + return [ + 'kind' => self::KIND_REDIS, + 'engine' => 'redis', + 'role' => $role, + 'service_name' => $serviceName, + 'host_port' => $hostPort, + 'compose' => implode("\n", $lines) . "\n", + 'env' => implode("\n", $envLines) . "\n", + 'credentials' => [ + 'label' => $serviceName, + 'host' => '', + 'port' => $hostPort, + 'database' => 0, + 'username' => '', + 'password' => $redisPassword, + ], + 'steps' => $steps, + ]; + } + + private static function minioComposeTemplate(array $input, string $role): array + { + $serviceName = self::composeIdentifier($input['service_name'] ?? null, 'minio-' . $role); + $volumeName = self::composeIdentifier($input['volume_name'] ?? null, $serviceName . '-data'); + $image = self::composeImage($input['image'] ?? null, 'minio/minio:latest'); + $mcImage = self::composeImage($input['mc_image'] ?? null, 'minio/mc:latest'); + $hostPort = self::boundedInt($input['host_port'] ?? null, $role === 'primary' ? 9000 : 9010, 1, 65535); + $consolePort = self::boundedInt($input['console_port'] ?? null, $role === 'primary' ? 9001 : 9011, 1, 65535); + $rootUser = self::composeAccessKey($input['username'] ?? $input['access_key'] ?? null); + $rootPassword = self::composePassword($input['password'] ?? $input['secret_key'] ?? null); + $buckets = self::normalizeMinioBuckets($input['buckets'] ?? self::MINIO_DEFAULT_BUCKETS); + $transferLimit = self::normalizeMinioTransferLimit( + $input['replication_transfer_limit'] ?? self::MINIO_DEFAULT_REPLICATION_TRANSFER_LIMIT, + true + ); + $primaryEndpoint = self::minioPrimaryComposeValue($input, 'endpoint'); + $primaryAccessKey = self::minioPrimaryComposeValue($input, 'access_key'); + $primarySecretKey = self::minioPrimaryComposeValue($input, 'secret_key'); + [$serverUrl, $browserRedirectUrl] = self::minioComposePublicUrls($input, $hostPort, $consolePort); + + $setupScript = [ + 'until mc alias set local http://' . self::shellArg($serviceName) . ':9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"; do sleep 2; done', + ]; + foreach ($buckets as $bucket) { + $bucketArg = self::shellArg('local/' . $bucket); + $setupScript[] = 'mc mb --with-lock --ignore-existing ' . $bucketArg; + $setupScript[] = 'mc version enable ' . $bucketArg . ' || true'; + if ($role === 'replica' && self::minioBucketUsesBoundedReplicaRetention($bucket)) { + $setupScript[] = 'mc ilm rule add --expire-days "' . self::MINIO_BACKUP_REPLICA_RETENTION_DAYS . '" --noncurrent-expire-days "' . self::MINIO_BACKUP_REPLICA_RETENTION_DAYS . '" ' . $bucketArg . ' || true'; + } + } + + $lines = [ + 'services:', + ' ' . $serviceName . ':', + ' image: ' . self::yamlQuote($image), + ' restart: unless-stopped', + ' command:', + ' - server', + ' - /data', + ' - --console-address', + ' - ":9001"', + ' environment:', + ' MINIO_ROOT_USER: "${MINIO_ROOT_USER:?set MINIO_ROOT_USER}"', + ' MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD}"', + ' MINIO_SERVER_URL: "${MINIO_SERVER_URL:-}"', + ' MINIO_BROWSER_REDIRECT_URL: "${MINIO_BROWSER_REDIRECT_URL:-}"', + ' volumes:', + ' - ' . $volumeName . ':/data', + ' ports:', + ' - ' . self::yamlQuote($hostPort . ':9000'), + ' - ' . self::yamlQuote($consolePort . ':9001'), + ' healthcheck:', + ' test:', + ' - "CMD"', + ' - "curl"', + ' - "-f"', + ' - "http://127.0.0.1:9000/minio/health/live"', + ' interval: 10s', + ' timeout: 5s', + ' retries: 12', + ' ' . $serviceName . '-setup:', + ' image: ' . self::yamlQuote($mcImage), + ' restart: "no"', + ' depends_on:', + ' ' . $serviceName . ':', + ' condition: service_healthy', + ' environment:', + ' MINIO_ROOT_USER: "${MINIO_ROOT_USER:?set MINIO_ROOT_USER}"', + ' MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD}"', + ]; + if ($role === 'replica') { + $lines[] = ' MINIO_PRIMARY_ENDPOINT: "${MINIO_PRIMARY_ENDPOINT:-}"'; + $lines[] = ' MINIO_PRIMARY_ACCESS_KEY: "${MINIO_PRIMARY_ACCESS_KEY:-}"'; + $lines[] = ' MINIO_PRIMARY_SECRET_KEY: "${MINIO_PRIMARY_SECRET_KEY:-}"'; + } + $lines = array_merge($lines, [ + ' entrypoint:', + ' - /bin/sh', + ' - -ec', + ' - |', + ]); + foreach ($setupScript as $scriptLine) { + $lines[] = ' ' . $scriptLine; + } + $lines = array_merge($lines, [ + 'volumes:', + ' ' . $volumeName . ':', + ]); + + $envLines = [ + 'MINIO_ROOT_USER=' . $rootUser, + 'MINIO_ROOT_PASSWORD=' . $rootPassword, + 'MINIO_SERVER_URL=' . $serverUrl, + 'MINIO_BROWSER_REDIRECT_URL=' . $browserRedirectUrl, + 'MINIO_BUCKETS=' . implode(',', $buckets), + ]; + if ($role === 'replica') { + $envLines[] = 'MINIO_BACKUP_REPLICA_RETENTION_DAYS=' . self::MINIO_BACKUP_REPLICA_RETENTION_DAYS; + $envLines[] = 'MINIO_REPLICATION_TRANSFER_LIMIT=' . $transferLimit; + $envLines[] = 'MINIO_PRIMARY_ENDPOINT=' . $primaryEndpoint; + $envLines[] = 'MINIO_PRIMARY_ACCESS_KEY=' . $primaryAccessKey; + $envLines[] = 'MINIO_PRIMARY_SECRET_KEY=' . $primarySecretKey; + } + + $steps = [ + 'Deploy this compose file as a normal Docker Compose or Coolify compose service.', + 'The setup service creates required buckets and enables bucket versioning.', + 'Add the MinIO credentials in the superuser UI after the API endpoint is reachable.', + ]; + if ($role === 'replica') { + $steps[] = 'Fill the MINIO_PRIMARY_* .env values for reference; managed bucket replication is configured from the superuser UI.'; + $steps[] = 'The backups bucket is retained on replicas for ' . self::MINIO_BACKUP_REPLICA_RETENTION_DAYS . ' days; other buckets are fully replicated.'; + $steps[] = 'Replica seeding and bucket replication are bandwidth-limited to ' . ($transferLimit !== '' ? $transferLimit : 'unlimited') . '.'; + $steps[] = 'Test the connection, then save and provision the replica.'; + } + + return [ + 'kind' => self::KIND_MINIO, + 'engine' => 'minio', + 'role' => $role, + 'service_name' => $serviceName, + 'host_port' => $hostPort, + 'console_port' => $consolePort, + 'compose' => implode("\n", $lines) . "\n", + 'env' => implode("\n", $envLines) . "\n", + 'credentials' => [ + 'label' => $serviceName, + 'host' => '', + 'port' => $hostPort, + 'scheme' => 'http', + 'endpoint' => '', + 'buckets' => $buckets, + 'console_port' => $consolePort, + 'username' => $rootUser, + 'password' => $rootPassword, + 'replication_transfer_limit' => $transferLimit, + 'space_headroom_percent' => self::MINIO_SPACE_HEADROOM_PERCENT, + ], + 'steps' => $steps, + ]; + } + + private static function normalizeComposeRole(string $role): string + { + $role = strtolower(trim($role)); + if (in_array($role, ['primary', 'replica'], true)) { + return $role; + } + throw new RuntimeException('Unsupported compose role.'); + } + + private static function composeIdentifier(mixed $value, string $fallback): string + { + $identifier = strtolower(trim((string)$value)); + $identifier = (string)preg_replace('/[^a-z0-9_.-]+/', '-', $identifier); + $identifier = trim($identifier, '-_.'); + return $identifier !== '' ? $identifier : $fallback; + } + + private static function composeScalar(mixed $value, string $fallback): string + { + $scalar = trim((string)$value); + return $scalar !== '' ? $scalar : $fallback; + } + + private static function composePassword(mixed $value): string + { + $password = trim((string)$value); + if ($password !== '') { + return $password; + } + + return self::generateSecret(24); + } + + private static function composeAccessKey(mixed $value): string + { + $accessKey = trim((string)$value); + if ($accessKey !== '') { + return $accessKey; + } + + return 'twminio' . bin2hex(random_bytes(12)); + } + + private static function minioPrimaryComposeValue(array $input, string $field): string + { + if ($field === 'endpoint') { + foreach (['primary_endpoint', 'minio_primary_endpoint'] as $key) { + $value = trim((string)($input[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + + $primaryHost = trim((string)($input['primary_host'] ?? '')); + if ($primaryHost !== '') { + if (preg_match('/^https?:\/\//i', $primaryHost) === 1) { + return $primaryHost; + } + + $primaryScheme = trim((string)($input['primary_scheme'] ?? 'http')) ?: 'http'; + $primaryPort = self::boundedInt($input['primary_port'] ?? null, 9000, 1, 65535); + return self::minioEndpointFromParts($primaryScheme, $primaryHost, $primaryPort); + } + } + + $inputKeys = match ($field) { + 'endpoint' => [], + 'access_key' => ['primary_access_key', 'minio_primary_access_key', 'primary_username'], + 'secret_key' => ['primary_secret_key', 'minio_primary_secret_key', 'primary_password'], + default => [], + }; + + foreach ($inputKeys as $key) { + $value = trim((string)($input[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + + $minioConfig = $GLOBALS['MINIO'] ?? null; + if (!is_array($minioConfig)) { + return ''; + } + + return trim((string)($minioConfig[$field] ?? '')); + } + + /** + * Public MinIO URLs keep browser redirects on the externally mapped ports. + */ + private static function minioComposePublicUrls(array $input, int $hostPort, int $consolePort): array + { + $rawHost = trim((string)($input['public_host'] ?? $input['host'] ?? $input['endpoint'] ?? '')); + if ($rawHost === '') { + return ['', '']; + } + + try { + [$host, , $scheme] = self::normalizeMinioAddress($rawHost, null, $input['scheme'] ?? null); + } catch (Throwable) { + return ['', '']; + } + + return [ + self::minioEndpointFromParts($scheme, $host, $hostPort), + self::minioEndpointFromParts($scheme, $host, $consolePort), + ]; + } + + private static function generateSecret(int $bytes): string + { + return rtrim(strtr(base64_encode(random_bytes($bytes)), '+/', '-_'), '='); + } + + private static function composeImage(mixed $value, string $fallback): string + { + $image = trim((string)$value); + if ($image === '' || preg_match('/^[a-zA-Z0-9._:\/-]+$/', $image) !== 1) { + return $fallback; + } + return $image; + } + + private static function boundedInt(mixed $value, int $fallback, int $min, int $max): int + { + if (filter_var($value, FILTER_VALIDATE_INT) === false) { + return $fallback; + } + return max($min, min($max, (int)$value)); + } + + private static function yamlQuote(mixed $value): string + { + $encoded = json_encode((string)$value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + return is_string($encoded) ? $encoded : '""'; + } + + private static function shellArg(string $value): string + { + return "'" . str_replace("'", "'\"'\"'", $value) . "'"; + } + + private static function mariaDbSchemaOnlyDumpIgnoreArgs(string $databaseExpression): string + { + return implode(' ', array_map( + static fn(string $tableName): string => '--ignore-table="' . $databaseExpression . '.' . $tableName . '"', + self::MARIADB_SCHEMA_ONLY_TABLES + )); + } + + private static function mariaDbSchemaOnlySeedCommandIgnoreArgs(string $database): array + { + return array_map( + static fn(string $tableName): string => '--ignore-table=' . self::shellArg($database . '.' . $tableName), + self::MARIADB_SCHEMA_ONLY_TABLES + ); + } + + private static function quoteIdentifier(string $identifier): string + { + return '`' . str_replace('`', '``', $identifier) . '`'; + } + + private static function sqlString(mysqli $connection, string $value): string + { + return "'" . $connection->real_escape_string($value) . "'"; + } + + public static function mysqlGtidIntervalCount(string $gtidSet): int + { + $count = 0; + foreach (self::parseMysqlGtidSet($gtidSet) as $intervals) { + foreach ($intervals as [$start, $end]) { + $count += max(0, $end - $start + 1); + } + } + return $count; + } + + public static function mysqlGtidCoveragePercent(string $sourceSet, string $executedSet): float + { + $source = self::parseMysqlGtidSet($sourceSet); + $executed = self::parseMysqlGtidSet($executedSet); + $total = 0; + $covered = 0; + + foreach ($source as $uuid => $sourceIntervals) { + foreach ($sourceIntervals as [$sourceStart, $sourceEnd]) { + $total += max(0, $sourceEnd - $sourceStart + 1); + foreach ($executed[$uuid] ?? [] as [$executedStart, $executedEnd]) { + $start = max($sourceStart, $executedStart); + $end = min($sourceEnd, $executedEnd); + if ($end >= $start) { + $covered += $end - $start + 1; + } + } + } + } + + if ($total === 0) { + return 100.0; + } + + return round(min(100, max(0, ($covered / $total) * 100)), 2); + } + + public static function redisOffsetPercent(int $primaryOffset, int $replicaOffset): float + { + if ($primaryOffset <= 0) { + return 100.0; + } + + return round(min(100, max(0, ($replicaOffset / $primaryOffset) * 100)), 2); + } + + public static function redisReplicationPercentFromInfo(array $primaryInfo, array $replicaInfo): float + { + $syncInProgress = (string)($replicaInfo['master_sync_in_progress'] ?? '0') === '1'; + if ($syncInProgress) { + $totalBytes = (int)($replicaInfo['master_sync_total_bytes'] ?? 0); + $leftBytes = (int)($replicaInfo['master_sync_left_bytes'] ?? 0); + if ($totalBytes <= 0) { + return 5.0; + } + + $copiedBytes = max(0, $totalBytes - max(0, $leftBytes)); + return round(min(99.99, max(5.0, ($copiedBytes / $totalBytes) * 100)), 2); + } + + $primaryOffset = (int)($primaryInfo['master_repl_offset'] ?? 0); + $replicaOffset = (int)($replicaInfo['slave_repl_offset'] ?? $replicaInfo['master_repl_offset'] ?? 0); + return self::redisOffsetPercent($primaryOffset, $replicaOffset); + } + + public static function redisProvisionProgress(float $replicationPercent, array $syncBlockers = []): float + { + if ($replicationPercent >= 100.0 && $syncBlockers === []) { + return 100.0; + } + + return round(min(99.99, max(5.0, $replicationPercent)), 2); + } + + public static function replicationHealthStatus( + bool $reachable, + string $role, + float $replicationPercent, + array $blockers, + bool $replicationChecked = true + ): string { + if (!$reachable) { + return 'down'; + } + + if ($blockers !== []) { + return 'degraded'; + } + + if ($replicationChecked && $role !== 'primary' && $replicationPercent < 100.0) { + return 'degraded'; + } + + return 'ok'; + } + + public static function minioRequiredFreeBytes(int $sourceBytes, float $headroomPercent = self::MINIO_SPACE_HEADROOM_PERCENT): int + { + return (int)ceil(max(0, $sourceBytes) * (1 + max(0.0, $headroomPercent) / 100)); + } + + public static function minioByteReplicationPercent(int $sourceBytes, int $replicaBytes): float + { + if ($sourceBytes <= 0) { + return 100.0; + } + + return round(min(100, max(0, ($replicaBytes / $sourceBytes) * 100)), 2); + } + + public static function minioProvisionProgress(array $status): float + { + $percent = round((float)($status['replication_percent'] ?? 0), 2); + $blockers = array_values(array_filter($status['blockers'] ?? [])); + if ($percent >= 100.0 && $blockers === []) { + return 100.0; + } + + $measured = !empty($status['raw']['storage']['measured']) + || (string)($status['raw']['progress_source'] ?? '') === 'minio_replicate_status'; + if ($measured) { + return min(self::MINIO_INCOMPLETE_PROGRESS_MAX_PERCENT, max(0.0, $percent)); + } + + return self::minioIncompleteProgress($percent); + } + + private static function minioIncompleteProgress(float $percent, float $minimum = 5.0): float + { + return round(min(self::MINIO_INCOMPLETE_PROGRESS_MAX_PERCENT, max($minimum, $percent)), 2); + } + + public static function minioReplicationProgressFromStatusOutput(mixed $value): ?array + { + if (is_string($value)) { + $textProgress = self::minioReplicationProgressFromText($value); + if ($textProgress !== null) { + return $textProgress; + } + + $decoded = self::decodeMinioJsonOutput($value); + if ($decoded !== null && $decoded !== $value) { + return self::minioReplicationProgressFromStatusOutput($decoded); + } + + return null; + } + + $stats = [ + 'completed_bytes' => 0.0, + 'pending_bytes' => 0.0, + 'failed_bytes' => 0.0, + 'total_bytes' => 0.0, + 'completed_count' => 0.0, + 'pending_count' => 0.0, + 'failed_count' => 0.0, + 'total_count' => 0.0, + 'complete_signals' => 0, + 'incomplete_signals' => 0, + ]; + self::collectMinioReplicationProgress($value, $stats); + + $completedBytes = (float)$stats['completed_bytes']; + $remainingBytes = (float)$stats['pending_bytes'] + (float)$stats['failed_bytes']; + $totalBytes = (float)$stats['total_bytes']; + $completedCount = (float)$stats['completed_count']; + $remainingCount = (float)$stats['pending_count'] + (float)$stats['failed_count']; + $totalCount = (float)$stats['total_count']; + $basis = null; + $percent = null; + + if ($totalBytes > 0.0) { + $percent = ($completedBytes / $totalBytes) * 100; + $basis = 'total_bytes'; + } elseif (($completedBytes + $remainingBytes) > 0.0) { + $percent = ($completedBytes / ($completedBytes + $remainingBytes)) * 100; + $basis = 'byte_balance'; + } elseif ($totalCount > 0.0) { + $percent = ($completedCount / $totalCount) * 100; + $basis = 'total_count'; + } elseif (($completedCount + $remainingCount) > 0.0) { + $percent = ($completedCount / ($completedCount + $remainingCount)) * 100; + $basis = 'count_balance'; + } elseif ((int)$stats['complete_signals'] > 0 && (int)$stats['incomplete_signals'] === 0) { + $percent = 100.0; + $basis = 'status_signal'; + } elseif ((int)$stats['incomplete_signals'] > 0) { + $percent = 5.0; + $basis = 'status_signal'; + } + + if ($percent === null) { + return null; + } + + $percent = round(min(100.0, max(0.0, $percent)), 2); + + return [ + 'replication_percent' => $percent, + 'blockers' => $percent < 100.0 ? [self::MINIO_REPLICA_NOT_CAUGHT_UP_BLOCKER] : [], + 'basis' => $basis, + 'stats' => $stats, + ]; + } + + public static function minioBackupReplicaRetentionDays(): int + { + return self::MINIO_BACKUP_REPLICA_RETENTION_DAYS; + } + + public static function minioBackupRetentionBlockers(array $stats): array + { + foreach ($stats['buckets'] ?? [] as $bucket) { + if (!is_array($bucket) || (string)($bucket['name'] ?? '') !== self::MINIO_BACKUP_BUCKET) { + continue; + } + + $expiredObjects = (int)($bucket['expired_objects'] ?? 0); + if ($expiredObjects <= 0) { + return []; + } + + return [ + 'MinIO backup replica contains ' . $expiredObjects . ' backup object' + . ($expiredObjects === 1 ? '' : 's') . ' older than ' + . self::MINIO_BACKUP_REPLICA_RETENTION_DAYS . ' days. Run provisioning to prune retained backups.', + ]; + } + + return []; + } + + public static function minioSpaceBlockers(?int $availableBytes, int $requiredBytes): array + { + if ($availableBytes === null) { + return []; + } + if ($availableBytes < $requiredBytes) { + return ['MinIO target does not have enough free space. Required ' . $requiredBytes . ' bytes, available ' . $availableBytes . ' bytes.']; + } + + return []; + } + + public static function normalizeMinioBuckets(mixed $value): array + { + if (is_string($value)) { + $value = preg_split('/[\s,]+/', $value); + } + if (!is_array($value)) { + $value = self::MINIO_DEFAULT_BUCKETS; + } + + $buckets = []; + foreach ($value as $bucket) { + $bucket = strtolower(trim((string)$bucket)); + if ($bucket === '' || preg_match('/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/', $bucket) !== 1) { + continue; + } + $buckets[] = $bucket; + } + + $buckets = array_values(array_unique($buckets)); + return $buckets !== [] ? $buckets : self::MINIO_DEFAULT_BUCKETS; + } + + private static function minioBucketUsesBoundedReplicaRetention(string $bucket): bool + { + return strtolower(trim($bucket)) === self::MINIO_BACKUP_BUCKET; + } + + public static function minioBucketCountsTowardCatchUp(string $bucket): bool + { + return !self::minioBucketUsesBoundedReplicaRetention($bucket); + } + + private static function minioReplicaRetentionDaysByBucket(array $buckets): array + { + $retention = []; + foreach ($buckets as $bucket) { + if (self::minioBucketUsesBoundedReplicaRetention((string)$bucket)) { + $retention[(string)$bucket] = self::MINIO_BACKUP_REPLICA_RETENTION_DAYS; + } + } + + return $retention; + } + + public static function minioDefaultReplicationTransferLimit(): string + { + return self::MINIO_DEFAULT_REPLICATION_TRANSFER_LIMIT; + } + + public static function normalizeMinioTransferLimit(mixed $value, bool $defaultWhenEmpty = true): string + { + $raw = trim((string)$value); + if ($raw === '') { + return $defaultWhenEmpty ? self::MINIO_DEFAULT_REPLICATION_TRANSFER_LIMIT : ''; + } + + $normalized = preg_replace('/\s+/', '', $raw) ?? $raw; + $lower = strtolower($normalized); + if (in_array($lower, ['0', 'none', 'off', 'unlimited', 'disabled'], true)) { + return ''; + } + + $normalized = preg_replace('/\/s$/i', '', $normalized) ?? $normalized; + if (preg_match('/^(\d+(?:\.\d+)?)([a-zA-Z]*)$/', $normalized, $matches) !== 1) { + throw new RuntimeException('MinIO transfer limit must be empty, 0, or a rate like 25Mi, 100M, or 1G.'); + } + + $amount = $matches[1]; + if (str_contains($amount, '.')) { + $amount = rtrim(rtrim($amount, '0'), '.'); + } + if ($amount === '' || (float)$amount <= 0) { + return ''; + } + + $unit = $matches[2]; + $unitMap = [ + '' => '', + 'b' => 'B', + 'k' => 'K', + 'kb' => 'K', + 'm' => 'M', + 'mb' => 'M', + 'g' => 'G', + 'gb' => 'G', + 't' => 'T', + 'tb' => 'T', + 'ki' => 'Ki', + 'kib' => 'Ki', + 'mi' => 'Mi', + 'mib' => 'Mi', + 'gi' => 'Gi', + 'gib' => 'Gi', + 'ti' => 'Ti', + 'tib' => 'Ti', + ]; + $unitKey = strtolower($unit); + if (!array_key_exists($unitKey, $unitMap)) { + throw new RuntimeException('MinIO transfer limit must use B, K, M, G, T, Ki, Mi, Gi, or Ti units.'); + } + + return $amount . $unitMap[$unitKey]; + } + + private static function minioReplicationTransferLimitFromOptions(array $options): string + { + foreach (['replication_transfer_limit', 'transfer_limit', 'bandwidth_limit'] as $key) { + if (array_key_exists($key, $options)) { + return self::normalizeMinioTransferLimit($options[$key], false); + } + } + + return self::MINIO_DEFAULT_REPLICATION_TRANSFER_LIMIT; + } + + private static function minioReplicationTransferLimitArgs(string $transferLimit): array + { + $transferLimit = self::normalizeMinioTransferLimit($transferLimit, false); + if ($transferLimit === '') { + return []; + } + + return ['--limit-upload', $transferLimit, '--limit-download', $transferLimit]; + } + + private static function minioReplicationProgressFromText(string $output): ?array + { + if (preg_match_all('/(? $percent > 0.0)); + $percent = $nonZero !== [] ? min($nonZero) : 0.0; + $percent = round(min(100.0, max(0.0, $percent)), 2); + + return [ + 'replication_percent' => $percent, + 'blockers' => $percent < 100.0 ? [self::MINIO_REPLICA_NOT_CAUGHT_UP_BLOCKER] : [], + 'basis' => 'text_percent', + 'stats' => [ + 'percent_values' => $percentages, + ], + ]; + } + + private static function collectMinioReplicationProgress(mixed $value, array &$stats, array $path = []): void + { + if (is_object($value)) { + $value = get_object_vars($value); + } + if (!is_array($value)) { + return; + } + + foreach ($value as $key => $entry) { + $normalizedKey = self::normalizeMinioProgressKey((string)$key); + $nextPath = array_values(array_filter(array_merge($path, [$normalizedKey]), static fn(string $part): bool => $part !== '')); + + if (is_numeric($entry)) { + self::collectMinioReplicationProgressNumber($nextPath, (float)$entry, $stats); + continue; + } + + if (is_string($entry)) { + self::collectMinioReplicationProgressString($entry, $stats); + $textProgress = self::minioReplicationProgressFromText($entry); + if ($textProgress !== null) { + $stats['completed_count'] += (float)$textProgress['replication_percent']; + $stats['total_count'] += 100.0; + } + continue; + } + + self::collectMinioReplicationProgress($entry, $stats, $nextPath); + } + } + + private static function collectMinioReplicationProgressNumber(array $path, float $value, array &$stats): void + { + if ($value < 0.0) { + return; + } + + $pathText = implode('', $path); + foreach ([ + 'percent', + 'percentage', + 'duration', + 'elapsed', + 'timestamp', + 'time', + 'priority', + 'port', + 'versionid', + 'avg', + 'average', + 'peak', + 'rate', + 'latency', + 'uptime', + 'downtime', + 'lastminute', + 'lasthour', + 'last1hr', + 'last1m', + 'last5min', + 'sinceuptime', + ] as $ignored) { + if (str_contains($pathText, $ignored)) { + return; + } + } + + $category = null; + foreach (['failed', 'failure', 'failures', 'error', 'errors'] as $needle) { + if (str_contains($pathText, $needle)) { + $category = 'failed'; + break; + } + } + if ($category === null) { + foreach (['pending', 'queued', 'queue', 'backlog', 'remaining', 'unreplicated', 'inprogress', 'missing'] as $needle) { + if (str_contains($pathText, $needle)) { + $category = 'pending'; + break; + } + } + } + if ($category === null) { + foreach (['completed', 'complete', 'replicated', 'replicate', 'replica', 'success', 'synced'] as $needle) { + if (str_contains($pathText, $needle)) { + $category = 'completed'; + break; + } + } + } + if ($category === null && str_contains($pathText, 'total')) { + $category = 'total'; + } + if ($category === null) { + return; + } + + $isBytes = str_contains($pathText, 'byte') + || str_contains($pathText, 'bytes') + || str_contains($pathText, 'size'); + $suffix = $isBytes ? 'bytes' : 'count'; + $stats[$category . '_' . $suffix] += $value; + } + + private static function collectMinioReplicationProgressString(string $value, array &$stats): void + { + $normalized = self::normalizeMinioProgressKey($value); + if ($normalized === '') { + return; + } + + foreach (['pending', 'queued', 'backlog', 'replicating', 'syncing', 'inprogress', 'failed', 'failure', 'error'] as $needle) { + if (str_contains($normalized, $needle)) { + $stats['incomplete_signals']++; + return; + } + } + + foreach (['completed', 'complete', 'replicated', 'synced', 'success', 'healthy', 'ok'] as $needle) { + if (str_contains($normalized, $needle)) { + $stats['complete_signals']++; + return; + } + } + } + + private static function normalizeMinioProgressKey(string $value): string + { + return strtolower((string)preg_replace('/[^a-zA-Z0-9]+/', '', $value)); + } + + public static function mariadbGtidCoveragePercent(string $sourceSet, string $replicaSet): float + { + $source = self::parseMariaDbGtidSet($sourceSet); + $replica = self::parseMariaDbGtidSet($replicaSet); + $total = array_sum($source); + if ($total <= 0) { + return 100.0; + } + + $covered = 0; + foreach ($source as $domain => $sourceSequence) { + $covered += min($sourceSequence, $replica[$domain] ?? 0); + } + + return round(min(100, max(0, ($covered / $total) * 100)), 2); + } + + private static function parseMariaDbGtidSet(string $gtidSet): array + { + $positions = []; + foreach (explode(',', trim($gtidSet)) as $gtid) { + $gtid = trim($gtid); + if ($gtid === '') { + continue; + } + + $parts = explode('-', $gtid); + if (count($parts) !== 3) { + continue; + } + + [$domain, , $sequence] = array_map('intval', $parts); + if ($sequence <= 0) { + continue; + } + + $positions[$domain] = max($positions[$domain] ?? 0, $sequence); + } + + return $positions; + } + + private static function parseMysqlGtidSet(string $gtidSet): array + { + $parsed = []; + foreach (explode(',', trim($gtidSet)) as $uuidSet) { + $uuidSet = trim($uuidSet); + if ($uuidSet === '') { + continue; + } + + $parts = explode(':', $uuidSet); + if (count($parts) < 2) { + continue; + } + + $uuid = strtolower(array_shift($parts)); + foreach ($parts as $interval) { + if (str_contains($interval, '-')) { + [$start, $end] = array_map('intval', explode('-', $interval, 2)); + } else { + $start = $end = (int)$interval; + } + if ($start <= 0 || $end <= 0) { + continue; + } + if ($end < $start) { + [$start, $end] = [$end, $start]; + } + $parsed[$uuid][] = [$start, $end]; + } + } + + foreach ($parsed as $uuid => $intervals) { + usort($intervals, static fn(array $a, array $b): int => $a[0] <=> $b[0]); + $merged = []; + foreach ($intervals as [$start, $end]) { + $lastIndex = count($merged) - 1; + if ($lastIndex >= 0 && $start <= $merged[$lastIndex][1] + 1) { + $merged[$lastIndex][1] = max($merged[$lastIndex][1], $end); + continue; + } + $merged[] = [$start, $end]; + } + $parsed[$uuid] = $merged; + } + + return $parsed; + } + + private function provisionDatabaseHost(array $host, int $operationId): array + { + $primary = $this->primaryHost(self::KIND_DATABASE); + if ($primary === null) { + throw new RuntimeException('No database primary is registered.'); + } + + $options = $this->decodeOptions($host); + $usePreseededReplica = !empty($options['allow_preseeded_replica']); + $targetStatus = $this->testDatabaseHost(array_merge($host, [ + 'test_connectivity_only' => true, + 'connect_without_database' => $usePreseededReplica, + ])); + $primaryStatus = $this->testDatabaseHost($primary); + $blockers = array_merge($targetStatus['blockers'], $primaryStatus['blockers']); + $targetEngine = self::databaseEngine($targetStatus['raw'] ?? []); + $primaryEngine = self::databaseEngine($primaryStatus['raw'] ?? []); + $targetEngineKnown = self::databaseEngineKnown($targetStatus['raw'] ?? []); + $primaryEngineKnown = self::databaseEngineKnown($primaryStatus['raw'] ?? []); + + if (($targetStatus['raw']['server_id'] ?? null) !== null + && ($primaryStatus['raw']['server_id'] ?? null) !== null + && (int)$targetStatus['raw']['server_id'] === (int)$primaryStatus['raw']['server_id']) { + $blockers[] = 'Database replica must have a unique server_id.'; + } + if ($targetEngineKnown && $primaryEngineKnown && $targetEngine !== $primaryEngine) { + $blockers[] = 'Database primary and replica must use the same engine family.'; + } + + $cloneReady = (bool)($targetStatus['raw']['clone_plugin_active'] ?? false); + $primaryCloneReady = (bool)($primaryStatus['raw']['clone_plugin_active'] ?? false); + if ($targetEngineKnown && $targetEngine === 'mariadb' && !$usePreseededReplica) { + $blockers[] = 'MariaDB replicas must be safely seeded before managed replication can be configured.'; + } + if ($targetEngineKnown && $targetEngine === 'mysql' && !$usePreseededReplica) { + if (!$cloneReady) { + $blockers[] = 'MySQL Clone plugin is not active on the target. Set allow_preseeded_replica only after the target has been safely seeded.'; + } + if (!$primaryCloneReady) { + $blockers[] = 'MySQL Clone plugin is not active on the primary donor.'; + } + } + + if ($blockers !== []) { + $this->storeStatus($host, array_replace($targetStatus, ['blockers' => array_values(array_unique($blockers))])); + return [ + 'ok' => false, + 'message' => 'Database replica provisioning is blocked.', + 'blockers' => array_values(array_unique($blockers)), + 'replication_percent' => $targetStatus['replication_percent'], + 'host' => $this->publicHost($host), + ]; + } + + $target = $this->databaseConnection($host, true, $usePreseededReplica); + try { + $primaryCredentials = $this->credentials($primary); + $hostCredentials = $this->credentials($host); + $replicationUser = $hostCredentials['replication_username'] + ?: ($primaryCredentials['replication_username'] ?: $primaryCredentials['username']); + $replicationPassword = $hostCredentials['replication_password'] + ?: ($primaryCredentials['replication_password'] ?: $primaryCredentials['password']); + $shouldManageReplicationUser = ($hostCredentials['replication_username'] ?? '') !== '' + && ($hostCredentials['replication_password'] ?? '') !== ''; + + if ($usePreseededReplica && $targetEngine === 'mariadb') { + $seedContext = $this->operationContext($operationId); + $seedInProgress = ($seedContext['phase'] ?? '') !== '' && ($seedContext['phase'] ?? '') !== 'complete'; + $seedBlockers = $this->databaseReplicaSeedBlockers($primary, $host, $target); + if ($seedInProgress || $seedBlockers !== []) { + $seedResult = $this->advanceMariaDbReplicaSeed($operationId, $primary, $host, $target); + $targetStatus = $seedResult['status']; + + if (($seedResult['running'] ?? false) === true) { + $this->storeStatus($host, $targetStatus); + return [ + 'ok' => true, + 'message' => $seedResult['message'], + 'blockers' => [], + 'replication_percent' => $targetStatus['replication_percent'], + 'operation' => [ + 'id' => $operationId, + 'status' => 'running', + 'progress_percent' => $targetStatus['replication_percent'], + 'message' => $seedResult['message'], + ], + 'host' => $this->publicHost($host), + ]; + } + } + } elseif ($usePreseededReplica) { + $seedBlockers = $this->databaseReplicaSeedBlockers($primary, $host, $target); + if ($seedBlockers !== []) { + $combinedBlockers = array_values(array_unique(array_merge($targetStatus['blockers'], $seedBlockers))); + $this->storeStatus($host, array_replace($targetStatus, [ + 'status' => 'degraded', + 'replication_percent' => min(99.99, (float)$targetStatus['replication_percent']), + 'blockers' => $combinedBlockers, + ])); + return [ + 'ok' => false, + 'message' => 'Database replica provisioning is blocked.', + 'blockers' => $combinedBlockers, + 'replication_percent' => min(99.99, (float)$targetStatus['replication_percent']), + 'host' => $this->publicHost($host), + ]; + } + } + + if ($shouldManageReplicationUser) { + $grantHosts = $this->databaseReplicationGrantHosts($host, $targetStatus); + $this->ensureDatabaseReplicationUser($primary, $replicationUser, $replicationPassword, $grantHosts); + } + + if ($targetEngine === 'mysql' && !$usePreseededReplica) { + $this->runMysqlClone($target, $primary, $replicationUser, $replicationPassword); + $target->close(); + $target = $this->waitForDatabaseConnection($host, true, 120); + } + + if ($targetEngine === 'mariadb') { + $this->configureMariaDbReplication($target, $primary, $host, $replicationUser, $replicationPassword); + } else { + $this->configureMySqlReplication($target, $primary, $replicationUser, $replicationPassword); + } + } finally { + $target->close(); + } + + $this->execute( + "UPDATE replication_hosts SET replication_source_id = ?, status = 'provisioning' WHERE id = ?", + 'ii', + [(int)$primary['id'], (int)$host['id']] + ); + + $status = $this->testDatabaseHost($this->getHost(self::KIND_DATABASE, (int)$host['id'])); + $this->storeStatus($host, $status); + + return [ + 'ok' => true, + 'healthy' => $status['blockers'] === [], + 'message' => $targetEngine === 'mariadb' + ? 'MariaDB replication was configured with GTID slave_pos.' + : 'Database replication was configured with GTID auto-positioning.', + 'blockers' => $status['blockers'], + 'replication_percent' => $status['replication_percent'], + 'host' => $this->publicHost($this->getHost(self::KIND_DATABASE, (int)$host['id'])), + ]; + } + + private function databaseReplicationGrantHosts(array $host, array $targetStatus = []): array + { + $grantHosts = ['%']; + if (isset($host['host'])) { + $grantHosts[] = (string)$host['host']; + } + + $lastStatus = self::jsonDecode($host['last_status_json'] ?? null); + foreach ([$targetStatus, $lastStatus] as $status) { + if (!is_array($status)) { + continue; + } + + foreach ($this->databaseDeniedAccountHostsFromStatus($status) as $deniedHost) { + $grantHosts[] = $deniedHost; + } + } + + $normalized = []; + foreach ($grantHosts as $grantHost) { + foreach (self::databaseAccountHostGrantCandidates((string)$grantHost) as $candidate) { + if (!in_array($candidate, $normalized, true)) { + $normalized[] = $candidate; + } + } + } + + return $normalized === [] ? ['%'] : $normalized; + } + + private function databaseDeniedAccountHostsFromStatus(array $status): array + { + $hosts = []; + + foreach ($status['blockers'] ?? [] as $blocker) { + if (is_scalar($blocker)) { + $hosts = array_merge($hosts, self::databaseDeniedAccountHostsFromText((string)$blocker)); + } + } + + $replicaStatus = $status['raw']['replica_status'] ?? []; + if (is_array($replicaStatus)) { + foreach (['Last_IO_Error', 'Last_SQL_Error', 'Last_Error'] as $errorKey) { + $error = trim((string)($replicaStatus[$errorKey] ?? '')); + if ($error !== '') { + $hosts = array_merge($hosts, self::databaseDeniedAccountHostsFromText($error)); + } + } + } + + return array_values(array_unique($hosts)); + } + + private static function databaseDeniedAccountHostsFromText(string $text): array + { + preg_match_all('/Access denied for user\s+[\'"][^\'"]+[\'"]@[\'"]([^\'"]+)[\'"]/i', $text, $matches); + return array_values(array_unique(array_filter($matches[1] ?? []))); + } + + private static function normalizeDatabaseAccountHost(string $host): ?string + { + $host = trim($host); + if ($host === '') { + return null; + } + + if ($host !== '%') { + $host = trim($host, '[]'); + } + + if ($host === '' || strlen($host) > 255) { + return null; + } + + if (preg_match('/[\s\'"`;\\\\]/', $host)) { + return null; + } + + return preg_match('/^[A-Za-z0-9_.:%-]+$/', $host) === 1 ? $host : null; + } + + private static function databaseAccountHostGrantCandidates(string $host): array + { + $host = self::normalizeDatabaseAccountHost($host); + if ($host === null) { + return []; + } + + $candidates = [$host]; + if ($host !== '%' && !str_contains($host, '%')) { + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { + $lastDot = strrpos($host, '.'); + if ($lastDot !== false) { + $candidates[] = substr($host, 0, $lastDot + 1) . '%'; + } + } elseif (str_contains($host, ':')) { + $lastColon = strrpos($host, ':'); + if ($lastColon !== false) { + $candidates[] = substr($host, 0, $lastColon + 1) . '%'; + } + } + } + + return array_values(array_unique(array_filter(array_map( + static fn(string $candidate): ?string => self::normalizeDatabaseAccountHost($candidate), + $candidates + )))); + } + + private function ensureDatabaseReplicationUser(array $primary, string $replicationUser, string $replicationPassword, array $grantHosts = []): void + { + if (trim($replicationUser) === '' || trim($replicationPassword) === '') { + throw new RuntimeException('Replication username and password are required.'); + } + + $connection = $this->databaseConnection($primary, true); + try { + $grantHosts = $grantHosts === [] ? ['%'] : $grantHosts; + $user = $connection->real_escape_string($replicationUser); + $password = $connection->real_escape_string($replicationPassword); + + foreach ($grantHosts as $grantHost) { + $grantHost = self::normalizeDatabaseAccountHost((string)$grantHost); + if ($grantHost === null) { + continue; + } + + $account = sprintf( + "'%s'@'%s'", + $user, + $connection->real_escape_string($grantHost) + ); + + $this->mysqliExec($connection, "CREATE USER IF NOT EXISTS " . $account . " IDENTIFIED BY '" . $password . "'"); + $this->mysqliExec($connection, "ALTER USER " . $account . " IDENTIFIED BY '" . $password . "'"); + $this->mysqliExec($connection, "GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO " . $account); + } + $this->mysqliExec($connection, 'FLUSH PRIVILEGES'); + } catch (Throwable $throwable) { + throw new RuntimeException( + 'Could not create or update the replication user on the primary database. Add primary admin credentials or create the replication user manually: ' . $throwable->getMessage(), + 0, + $throwable + ); + } finally { + $connection->close(); + } + } + + private function configureMySqlReplication(mysqli $target, array $primary, string $replicationUser, string $replicationPassword): void + { + try { + $this->mysqliExec($target, 'STOP REPLICA'); + } catch (Throwable) { + } + $sql = sprintf( + "CHANGE REPLICATION SOURCE TO SOURCE_HOST = '%s', SOURCE_PORT = %d, SOURCE_USER = '%s', SOURCE_PASSWORD = '%s', SOURCE_AUTO_POSITION = 1", + $target->real_escape_string((string)$primary['host']), + (int)$primary['port'], + $target->real_escape_string($replicationUser), + $target->real_escape_string($replicationPassword) + ); + $this->mysqliExec($target, $sql); + $this->mysqliExec($target, 'START REPLICA'); + } + + private function configureMariaDbReplication(mysqli $target, array $primary, array $host, string $replicationUser, string $replicationPassword): void + { + foreach (['STOP SLAVE', 'RESET SLAVE ALL'] as $statement) { + try { + $this->mysqliExec($target, $statement); + } catch (Throwable) { + } + } + $this->configureMariaDbReplicationFilters($target, $primary, $host); + $sql = sprintf( + "CHANGE MASTER TO MASTER_HOST = '%s', MASTER_PORT = %d, MASTER_USER = '%s', MASTER_PASSWORD = '%s', MASTER_USE_GTID = slave_pos", + $target->real_escape_string((string)$primary['host']), + (int)$primary['port'], + $target->real_escape_string($replicationUser), + $target->real_escape_string($replicationPassword) + ); + $this->mysqliExec($target, $sql); + $this->mysqliExec($target, 'START SLAVE'); + } + + private function configureMariaDbReplicationFilters(mysqli $target, array $primary, array $host): void + { + $existing = $this->mysqliSelectOne($target, "SHOW GLOBAL VARIABLES LIKE 'replicate_ignore_table'"); + $filters = array_values(array_filter(array_map( + static fn(string $filter): string => trim($filter), + explode(',', (string)($existing['Value'] ?? '')) + ))); + + foreach ([(string)($primary['database_name'] ?? ''), (string)($host['database_name'] ?? '')] as $database) { + $database = trim($database); + if ($database !== '') { + foreach (self::MARIADB_SCHEMA_ONLY_TABLES as $tableName) { + $filters[] = $database . '.' . $tableName; + } + } + } + + $filters = array_values(array_unique($filters)); + if ($filters === []) { + return; + } + + try { + $this->mysqliExec($target, 'SET GLOBAL replicate_ignore_table = ' . self::sqlString($target, implode(',', $filters))); + } catch (Throwable $throwable) { + throw new RuntimeException( + 'Could not configure MariaDB replica schema-only table filters: ' . $throwable->getMessage(), + 0, + $throwable + ); + } + } + + private function runMysqlClone(mysqli $target, array $primary, string $cloneUser, string $clonePassword): void + { + $donor = $target->real_escape_string((string)$primary['host'] . ':' . (int)$primary['port']); + $this->mysqliExec($target, "SET GLOBAL clone_valid_donor_list = '" . $donor . "'"); + + $sql = sprintf( + "CLONE INSTANCE FROM '%s'@'%s':%d IDENTIFIED BY '%s'", + $target->real_escape_string($cloneUser), + $target->real_escape_string((string)$primary['host']), + (int)$primary['port'], + $target->real_escape_string($clonePassword) + ); + + try { + $this->mysqliExec($target, $sql); + } catch (Throwable $throwable) { + $message = strtolower($throwable->getMessage()); + if (!str_contains($message, 'lost connection') && !str_contains($message, 'server has gone away')) { + throw $throwable; + } + } + } + + private function waitForDatabaseConnection(array $host, bool $admin, int $timeoutSeconds): mysqli + { + $deadline = time() + max(1, $timeoutSeconds); + $lastError = null; + + do { + try { + return $this->databaseConnection($host, $admin); + } catch (Throwable $throwable) { + $lastError = $throwable; + sleep(2); + } + } while (time() < $deadline); + + throw new RuntimeException('Database target did not reconnect after MySQL Clone: ' . ($lastError?->getMessage() ?? 'timeout')); + } + + private function provisionRedisHost(array $host, int $operationId): array + { + $primary = $this->primaryHost(self::KIND_REDIS); + if ($primary === null) { + throw new RuntimeException('No Redis primary is registered.'); + } + + $targetStatus = $this->testRedisHost(array_merge($host, ['test_connectivity_only' => true])); + $primaryStatus = $this->testRedisHost($primary); + $blockers = array_merge($targetStatus['blockers'], $primaryStatus['blockers']); + if ($blockers !== []) { + $this->storeStatus($host, array_replace($targetStatus, ['blockers' => array_values(array_unique($blockers))])); + return [ + 'ok' => false, + 'message' => 'Redis replica provisioning is blocked.', + 'blockers' => array_values(array_unique($blockers)), + 'replication_percent' => $targetStatus['replication_percent'], + 'host' => $this->publicHost($host), + ]; + } + + $context = $this->operationContext($operationId); + if (($context['phase'] ?? '') !== 'configured') { + try { + $client = $this->redisClient($host); + $primaryCredentials = $this->credentials($primary); + if (($primaryCredentials['username'] ?? '') !== '' && ($primaryCredentials['username'] ?? '') !== 'default') { + $client->executeRaw(['CONFIG', 'SET', 'masteruser', (string)$primaryCredentials['username']]); + } + $client->executeRaw(['CONFIG', 'SET', 'masterauth', (string)($primaryCredentials['password'] ?? '')]); + $client->executeRaw(['REPLICAOF', (string)$primary['host'], (string)$primary['port']]); + $client->executeRaw(['CONFIG', 'REWRITE']); + } catch (Throwable $throwable) { + $blockers = [$throwable->getMessage()]; + $this->storeStatus($host, array_replace($targetStatus, [ + 'status' => 'degraded', + 'replication_percent' => 0, + 'blockers' => $blockers, + ])); + return [ + 'ok' => false, + 'message' => 'Redis replica provisioning is blocked.', + 'blockers' => $blockers, + 'replication_percent' => 0, + 'host' => $this->publicHost($host), + ]; + } + + $context = [ + 'phase' => 'configured', + 'configured_at' => date('c'), + 'primary_host' => (string)$primary['host'], + 'primary_port' => (int)$primary['port'], + ]; + $this->updateOperationProgress( + $operationId, + 5.0, + 'Redis replication was configured; waiting for the replica to catch up.', + $context + ); + } + + $this->execute( + "UPDATE replication_hosts SET replication_source_id = ?, status = 'provisioning' WHERE id = ?", + 'ii', + [(int)$primary['id'], (int)$host['id']] + ); + + $host = $this->getHost(self::KIND_REDIS, (int)$host['id']); + $status = $this->testRedisHost($host); + $syncBlockers = array_values(array_intersect($status['blockers'], [ + 'Redis host is not currently a replica.', + 'Redis replica link to primary is not up.', + ])); + $onlySyncBlockers = $status['blockers'] === [] + || ($syncBlockers !== [] && count($syncBlockers) === count($status['blockers'])); + $progress = self::redisProvisionProgress((float)$status['replication_percent'], $syncBlockers); + + if ($onlySyncBlockers && ((float)$status['replication_percent'] < 100.0 || $syncBlockers !== [])) { + $message = $syncBlockers !== [] + ? 'Redis replication is configured, but the replica is waiting for the primary link.' + : 'Redis replication is configured and syncing in the background.'; + $this->storeStatus($host, array_replace($status, ['replication_percent' => $progress])); + $this->updateOperationProgress($operationId, $progress, $message, $context); + return [ + 'ok' => true, + 'healthy' => false, + 'message' => $message, + 'blockers' => $status['blockers'], + 'replication_percent' => $progress, + 'host' => $this->publicHost($this->getHost(self::KIND_REDIS, (int)$host['id'])), + ]; + } + + $this->storeStatus($host, $status); + if ($status['blockers'] !== []) { + return [ + 'ok' => false, + 'message' => 'Redis replica provisioning is blocked.', + 'blockers' => $status['blockers'], + 'replication_percent' => $status['replication_percent'], + 'host' => $this->publicHost($host), + ]; + } + + return [ + 'ok' => true, + 'healthy' => $status['blockers'] === [], + 'message' => 'Redis replication was configured.', + 'blockers' => $status['blockers'], + 'replication_percent' => $status['replication_percent'], + 'host' => $this->publicHost($this->getHost(self::KIND_REDIS, (int)$host['id'])), + ]; + } + + private function provisionMinioHost(array $host, int $operationId): array + { + $primary = $this->primaryHost(self::KIND_MINIO); + if ($primary === null) { + throw new RuntimeException('No MinIO primary is registered.'); + } + + $targetStatus = $this->testMinioHost(array_merge($host, [ + 'test_connectivity_only' => true, + 'skip_storage_scan' => true, + ])); + $primaryStatus = $this->testMinioHost(array_merge($primary, [ + 'test_connectivity_only' => true, + 'skip_storage_scan' => true, + ])); + $blockers = array_values(array_unique(array_merge($targetStatus['blockers'], $primaryStatus['blockers']))); + if ($blockers !== []) { + $this->storeStatus($host, array_replace($targetStatus, ['blockers' => $blockers])); + return [ + 'ok' => false, + 'message' => 'MinIO replica provisioning is blocked.', + 'blockers' => $blockers, + 'replication_percent' => $targetStatus['replication_percent'], + 'host' => $this->publicHost($host), + ]; + } + + $context = $this->operationContext($operationId); + $replicationConfigured = $this->minioReplicationConfiguredForHosts($primary, $host); + if (!$replicationConfigured) { + try { + $this->configureMinioReplication($primary, $host); + } catch (Throwable $throwable) { + $blockers = [$throwable->getMessage()]; + $this->storeStatus($host, array_replace($targetStatus, [ + 'status' => 'degraded', + 'replication_percent' => 0, + 'blockers' => $blockers, + ])); + return [ + 'ok' => false, + 'message' => 'MinIO replica provisioning is blocked.', + 'blockers' => $blockers, + 'replication_percent' => 0, + 'host' => $this->publicHost($host), + ]; + } + + $context = [ + 'phase' => 'configured', + 'configured_at' => date('c'), + 'primary_endpoint' => self::minioEndpoint($primary), + ]; + $this->updateOperationProgress( + $operationId, + 5.0, + 'MinIO bucket replication was configured; waiting for buckets to catch up.', + $context + ); + } + + $this->execute( + "UPDATE replication_hosts SET replication_source_id = ?, status = 'provisioning' WHERE id = ?", + 'ii', + [(int)$primary['id'], (int)$host['id']] + ); + + $host = $this->getHost(self::KIND_MINIO, (int)$host['id']); + $status = $this->minioProvisionStatus($primary, $host); + $this->storeStatus($host, $status); + + $progress = self::minioProvisionProgress($status); + $syncInProgress = ((float)$status['replication_percent'] < 100.0 || $status['blockers'] !== []) + && self::minioOnlyProgressBlockers($status['blockers']); + if ($syncInProgress) { + $message = self::minioProvisionProgressMessage($status); + $this->updateOperationProgress($operationId, $progress, $message, $context); + return [ + 'ok' => true, + 'message' => $message, + 'blockers' => $status['blockers'], + 'replication_percent' => $progress, + 'operation' => [ + 'id' => $operationId, + 'status' => 'running', + 'progress_percent' => $progress, + 'message' => $message, + ], + 'host' => $this->publicHost($this->getHost(self::KIND_MINIO, (int)$host['id'])), + ]; + } + + if ($status['blockers'] !== []) { + return [ + 'ok' => false, + 'message' => 'MinIO replica provisioning is blocked.', + 'blockers' => $status['blockers'], + 'replication_percent' => $status['replication_percent'], + 'host' => $this->publicHost($host), + ]; + } + + return [ + 'ok' => true, + 'healthy' => $status['blockers'] === [], + 'message' => 'MinIO bucket replication was configured.', + 'blockers' => $status['blockers'], + 'replication_percent' => $status['replication_percent'], + 'host' => $this->publicHost($this->getHost(self::KIND_MINIO, (int)$host['id'])), + ]; + } + + private function promoteDatabaseHost(array $host): array + { + $status = $this->testDatabaseHost($host); + if ($status['blockers'] !== [] || (float)$status['replication_percent'] < 100.0) { + throw new RuntimeException('Database promotion blocked: ' . implode(' ', $status['blockers'] ?: ['Replica is not caught up.'])); + } + + $oldPrimary = $this->primaryHost(self::KIND_DATABASE); + if ($oldPrimary === null) { + throw new RuntimeException('No current database primary is registered.'); + } + + $oldPrimaryConn = null; + $targetConn = null; + $metadataSwitched = false; + + try { + $oldPrimaryConn = $this->databaseConnection($oldPrimary, true); + $oldPrimaryStatus = $this->databaseServerStatus($oldPrimaryConn); + $this->setDatabaseReadOnly($oldPrimaryConn, $oldPrimaryStatus, true); + + $targetConn = $this->databaseConnection($host, true); + $targetStatus = $this->databaseServerStatus($targetConn); + $this->stopDatabaseReplication($targetConn, $targetStatus); + $this->setDatabaseReadOnly($targetConn, $targetStatus, false); + + $this->switchPrimary(self::KIND_DATABASE, (int)$host['id'], (int)$oldPrimary['id']); + $metadataSwitched = true; + $this->writeBootstrapSnapshot(); + } catch (Throwable $throwable) { + if (!$metadataSwitched && $oldPrimaryConn instanceof mysqli) { + try { + $this->setDatabaseReadOnly($oldPrimaryConn, $oldPrimaryStatus ?? [], false); + } catch (Throwable) { + } + } + throw $throwable; + } finally { + if ($targetConn instanceof mysqli) { + $targetConn->close(); + } + if ($oldPrimaryConn instanceof mysqli) { + $oldPrimaryConn->close(); + } + } + + return [ + 'ok' => true, + 'message' => 'Database replica promoted to primary.', + 'primary' => $this->publicHost($this->getHost(self::KIND_DATABASE, (int)$host['id'])), + 'prior_primary' => $this->publicHost($this->getHost(self::KIND_DATABASE, (int)$oldPrimary['id'], true)), + 'blockers' => [], + ]; + } + + private function setDatabaseReadOnly(mysqli $connection, array $status, bool $readOnly): void + { + $value = $readOnly ? 'ON' : 'OFF'; + if (array_key_exists('super_read_only', $status)) { + try { + $this->mysqliExec($connection, 'SET GLOBAL super_read_only = ' . $value); + } catch (Throwable) { + } + } + $this->mysqliExec($connection, 'SET GLOBAL read_only = ' . $value); + } + + private function stopDatabaseReplication(mysqli $connection, array $status): void + { + if (self::databaseEngine($status) === 'mariadb') { + $this->mysqliExec($connection, 'STOP SLAVE'); + return; + } + + $this->mysqliExec($connection, 'STOP REPLICA'); + } + + private function promoteRedisHost(array $host): array + { + $status = $this->testRedisHost($host); + if ($status['blockers'] !== [] || (float)$status['replication_percent'] < 100.0) { + throw new RuntimeException('Redis promotion blocked: ' . implode(' ', $status['blockers'] ?: ['Replica is not caught up.'])); + } + + $oldPrimary = $this->primaryHost(self::KIND_REDIS); + if ($oldPrimary === null) { + throw new RuntimeException('No current Redis primary is registered.'); + } + + $client = $this->redisClient($host); + $client->executeRaw(['REPLICAOF', 'NO', 'ONE']); + try { + $client->executeRaw(['CONFIG', 'REWRITE']); + } catch (Throwable) { + } + + $this->switchPrimary(self::KIND_REDIS, (int)$host['id'], (int)$oldPrimary['id']); + $this->writeBootstrapSnapshot(); + + return [ + 'ok' => true, + 'message' => 'Redis replica promoted to primary.', + 'primary' => $this->publicHost($this->getHost(self::KIND_REDIS, (int)$host['id'])), + 'prior_primary' => $this->publicHost($this->getHost(self::KIND_REDIS, (int)$oldPrimary['id'], true)), + 'blockers' => [], + ]; + } + + private function promoteMinioHost(array $host): array + { + $status = $this->testMinioHost($host); + if ($status['blockers'] !== [] || (float)$status['replication_percent'] < 100.0) { + throw new RuntimeException('MinIO promotion blocked: ' . implode(' ', $status['blockers'] ?: ['Replica is not caught up.'])); + } + + $oldPrimary = $this->primaryHost(self::KIND_MINIO); + if ($oldPrimary === null) { + throw new RuntimeException('No current MinIO primary is registered.'); + } + + $this->switchPrimary(self::KIND_MINIO, (int)$host['id'], (int)$oldPrimary['id']); + $this->writeBootstrapSnapshot(); + + return [ + 'ok' => true, + 'message' => 'MinIO replica promoted to primary.', + 'primary' => $this->publicHost($this->getHost(self::KIND_MINIO, (int)$host['id'])), + 'prior_primary' => $this->publicHost($this->getHost(self::KIND_MINIO, (int)$oldPrimary['id'], true)), + 'blockers' => [], + ]; + } + + private function runAutomaticFailoverForKind(string $kind, array $config, ?int $actorUserId): array + { + if (!replica_failover_manager::kindEnabled($config, $kind)) { + return [ + 'ok' => true, + 'status' => 'skipped', + 'reason' => 'disabled', + ]; + } + + $primary = $this->primaryHost($kind); + if ($primary === null) { + return [ + 'ok' => false, + 'status' => 'skipped', + 'reason' => 'missing_primary', + ]; + } + + if (!$this->primaryHostDown($kind, $primary)) { + return [ + 'ok' => true, + 'status' => 'skipped', + 'reason' => 'primary_healthy', + 'primary' => $this->publicHost($primary), + ]; + } + + $maxAgeSeconds = (int)$config['max_status_age_seconds']; + $candidate = replica_failover_manager::snapshotFailoverCandidate($this->listHosts($kind), $kind, $maxAgeSeconds); + if ($candidate === null) { + $result = [ + 'ok' => false, + 'status' => 'blocked', + 'reason' => 'no_fresh_caught_up_replica', + 'primary' => $this->publicHost($primary), + ]; + $this->audit($kind, (int)$primary['id'], 'automatic_failover_blocked', $actorUserId, 'warning', $result); + return $result; + } + + if (!replica_failover_manager::candidateReachable($kind, $candidate)) { + $result = [ + 'ok' => false, + 'status' => 'blocked', + 'reason' => 'candidate_unreachable', + 'primary' => $this->publicHost($primary), + 'candidate' => $this->publicHost($candidate), + ]; + $this->audit($kind, (int)$candidate['id'], 'automatic_failover_blocked', $actorUserId, 'warning', $result); + return $result; + } + + $operationId = $this->startOperation($kind, (int)$candidate['id'], 'automatic_failover', $actorUserId); + $owner = 'replication-auto-failover-' . $kind . '-' . (int)$candidate['id'] . '-' . bin2hex(random_bytes(4)); + $lockHandle = $this->acquirePromotionLock(); + + try { + application_write_freeze::freeze('Automatic replica failover in progress.', $owner, 600); + $result = match ($kind) { + self::KIND_DATABASE => $this->promoteDatabaseHostForFailover($candidate, $primary, $maxAgeSeconds), + self::KIND_REDIS => $this->promoteRedisHostForFailover($candidate, $primary, $maxAgeSeconds), + self::KIND_MINIO => $this->promoteMinioHostForFailover($candidate, $primary, $maxAgeSeconds), + }; + + $this->finishOperation($operationId, 'completed', 100, $result['message'] ?? null, []); + $this->audit($kind, (int)$candidate['id'], 'automatic_failover_promoted', $actorUserId, 'critical', $result); + return array_merge($result, [ + 'status' => 'promoted', + 'candidate' => $this->publicHost($this->getHost($kind, (int)$candidate['id'])), + ]); + } catch (Throwable $throwable) { + $this->finishOperation($operationId, 'failed', 0, null, [$throwable->getMessage()]); + $this->audit($kind, (int)$candidate['id'], 'automatic_failover_failed', $actorUserId, 'error', [ + 'error' => $throwable->getMessage(), + ]); + return [ + 'ok' => false, + 'status' => 'failed', + 'reason' => $throwable->getMessage(), + 'candidate' => $this->publicHost($candidate), + ]; + } finally { + application_write_freeze::unfreeze($owner); + $this->releasePromotionLock($lockHandle); + } + } + + private function primaryHostDown(string $kind, array $primary): bool + { + $activeConfig = replica_failover_manager::activeConfigFromHost($kind, $primary); + if ($activeConfig === null) { + return false; + } + + return replica_failover_manager::activePrimaryIsDown($kind, $activeConfig); + } + + private function promoteDatabaseHostForFailover(array $host, array $oldPrimary, int $maxAgeSeconds): array + { + if (!replica_failover_manager::snapshotHostIsStrictlyFresh($host, $maxAgeSeconds)) { + throw new RuntimeException('Database failover blocked: replica status is not fresh and caught up.'); + } + + $targetConn = $this->databaseConnection($host, true); + try { + $targetStatus = $this->databaseServerStatus($targetConn); + $this->stopDatabaseReplication($targetConn, $targetStatus); + $this->setDatabaseReadOnly($targetConn, $targetStatus, false); + $this->switchPrimary(self::KIND_DATABASE, (int)$host['id'], (int)$oldPrimary['id']); + $this->writeBootstrapSnapshot(); + replication_bootstrap_config::applyToGlobals(replication_bootstrap_config::loadSnapshot()); + } finally { + $targetConn->close(); + } + + return [ + 'ok' => true, + 'message' => 'Database replica promoted to primary after primary health check failed.', + 'primary' => $this->publicHost($this->getHost(self::KIND_DATABASE, (int)$host['id'])), + 'prior_primary' => $this->publicHost($this->getHost(self::KIND_DATABASE, (int)$oldPrimary['id'], true)), + 'blockers' => [], + ]; + } + + private function promoteRedisHostForFailover(array $host, array $oldPrimary, int $maxAgeSeconds): array + { + if (!replica_failover_manager::snapshotHostIsStrictlyFresh($host, $maxAgeSeconds)) { + throw new RuntimeException('Redis failover blocked: replica status is not fresh and caught up.'); + } + + $client = $this->redisClient($host); + $client->ping(); + $client->executeRaw(['REPLICAOF', 'NO', 'ONE']); + try { + $client->executeRaw(['CONFIG', 'REWRITE']); + } catch (Throwable) { + } + + $this->switchPrimary(self::KIND_REDIS, (int)$host['id'], (int)$oldPrimary['id']); + $this->writeBootstrapSnapshot(); + replication_bootstrap_config::applyToGlobals(replication_bootstrap_config::loadSnapshot()); + + return [ + 'ok' => true, + 'message' => 'Redis replica promoted to primary after primary health check failed.', + 'primary' => $this->publicHost($this->getHost(self::KIND_REDIS, (int)$host['id'])), + 'prior_primary' => $this->publicHost($this->getHost(self::KIND_REDIS, (int)$oldPrimary['id'], true)), + 'blockers' => [], + ]; + } + + private function promoteMinioHostForFailover(array $host, array $oldPrimary, int $maxAgeSeconds): array + { + if (!replica_failover_manager::snapshotHostIsStrictlyFresh($host, $maxAgeSeconds)) { + throw new RuntimeException('MinIO failover blocked: replica status is not fresh and caught up.'); + } + + $this->minioS3Client($host)->listBuckets(); + $this->switchPrimary(self::KIND_MINIO, (int)$host['id'], (int)$oldPrimary['id']); + $this->writeBootstrapSnapshot(); + replication_bootstrap_config::applyToGlobals(replication_bootstrap_config::loadSnapshot()); + + return [ + 'ok' => true, + 'message' => 'MinIO replica endpoint selected after primary health check failed.', + 'primary' => $this->publicHost($this->getHost(self::KIND_MINIO, (int)$host['id'])), + 'prior_primary' => $this->publicHost($this->getHost(self::KIND_MINIO, (int)$oldPrimary['id'], true)), + 'blockers' => [], + ]; + } + + private function testDatabaseHost(array $host): array + { + $blockers = []; + $raw = []; + $percent = (float)(($host['role'] ?? '') === 'primary' ? 100 : 0); + $lagSeconds = null; + $reachable = true; + + try { + $connection = $this->databaseConnection($host, true, !empty($host['connect_without_database'])); + try { + $raw = $this->databaseServerStatus($connection); + $blockers = array_merge($blockers, self::databasePrerequisiteBlockers($raw)); + + if (($host['role'] ?? '') !== 'primary' && empty($host['test_connectivity_only'])) { + $primary = $this->primaryHost(self::KIND_DATABASE); + if ($primary === null) { + $blockers[] = 'No database primary is registered.'; + } else { + $sourceConnection = $this->databaseConnection($primary, true); + try { + $source = $this->databaseServerStatus($sourceConnection); + $replica = $this->showReplicaStatus($connection); + $sourceEngine = self::databaseEngine($source); + $replicaEngine = self::databaseEngine($raw); + $raw['source_gtid_executed'] = self::databaseGtidPosition($source); + $raw['replica_status'] = $replica; + + if ($sourceEngine !== $replicaEngine) { + $blockers[] = 'Database primary and replica must use the same engine family.'; + } + $percent = $sourceEngine === 'mariadb' + ? self::mariadbGtidCoveragePercent( + self::databaseGtidPosition($source), + (string)($replica['Gtid_IO_Pos'] ?? $raw['gtid_slave_pos'] ?? $raw['gtid_current_pos'] ?? '') + ) + : self::mysqlGtidCoveragePercent( + (string)($source['gtid_executed'] ?? ''), + (string)($replica['Executed_Gtid_Set'] ?? $raw['gtid_executed'] ?? '') + ); + $lagSeconds = isset($replica['Seconds_Behind_Source']) + ? (int)$replica['Seconds_Behind_Source'] + : (isset($replica['Seconds_Behind_Master']) ? (int)$replica['Seconds_Behind_Master'] : null); + + $ioRunning = false; + $sqlRunning = false; + if ($replica === []) { + $blockers[] = 'Database replica status is not configured.'; + } else { + $ioRunning = strtoupper((string)($replica['Replica_IO_Running'] ?? $replica['Slave_IO_Running'] ?? '')) === 'YES'; + $sqlRunning = strtoupper((string)($replica['Replica_SQL_Running'] ?? $replica['Slave_SQL_Running'] ?? '')) === 'YES'; + if (!$ioRunning || !$sqlRunning) { + $blockers[] = 'Database replication IO and SQL threads must both be running.'; + } + if (!$ioRunning) { + $blockers[] = 'Database replication IO thread is not running.'; + } + if (!$sqlRunning) { + $blockers[] = 'Database replication SQL thread is not running.'; + } + if ($sourceEngine === 'mariadb' && isset($replica['Using_Gtid']) && strtoupper((string)$replica['Using_Gtid']) === 'NO') { + $blockers[] = 'MariaDB replication must use GTID mode.'; + } + foreach (['Last_IO_Error', 'Last_SQL_Error', 'Last_Error'] as $errorKey) { + $error = trim((string)($replica[$errorKey] ?? '')); + if ($error !== '') { + $blockers[] = $error; + } + } + } + if (($blockers !== [] || !$ioRunning || !$sqlRunning) && $percent >= 100.0) { + $percent = 99.99; + } + } finally { + $sourceConnection->close(); + } + } + } + } finally { + $connection->close(); + } + } catch (Throwable $throwable) { + $reachable = false; + $blockers[] = $throwable->getMessage(); + } + + $blockers = array_values(array_unique(array_filter($blockers))); + $status = [ + 'status' => self::replicationHealthStatus( + $reachable, + (string)($host['role'] ?? ''), + (float)$percent, + $blockers, + empty($host['test_connectivity_only']) + ), + 'replication_percent' => round($percent, 2), + 'lag_seconds' => $lagSeconds, + 'blockers' => $blockers, + 'raw' => $raw, + 'checked_at' => date('c'), + ]; + + if ($this->shouldRepairDatabaseReplicationAccess($host, $status)) { + $repair = $this->repairDatabaseReplicationAccess($host, $status); + if (($repair['ok'] ?? false) === true) { + $retested = $this->testDatabaseHost(array_merge($host, [ + 'skip_replication_access_repair' => true, + 'skip_replication_thread_repair' => true, + ])); + $retested['raw']['replication_access_repair'] = $repair; + return $retested; + } + + if (!empty($repair['message'])) { + $status['blockers'][] = 'Database replication access repair failed: ' . $repair['message']; + $status['blockers'] = array_values(array_unique(array_filter($status['blockers']))); + $status['status'] = self::replicationHealthStatus( + $reachable, + (string)($host['role'] ?? ''), + (float)$status['replication_percent'], + $status['blockers'] + ); + } + $status['raw']['replication_access_repair'] = $repair; + } + + if ($this->shouldRepairDatabaseReplicationThreads($host, $status)) { + $repair = $this->repairDatabaseReplicationThreads($host); + if (($repair['ok'] ?? false) === true) { + $retested = $this->testDatabaseHost(array_merge($host, [ + 'skip_replication_access_repair' => true, + 'skip_replication_thread_repair' => true, + ])); + $retested['raw']['replication_thread_repair'] = $repair; + return $retested; + } + + if (!empty($repair['message'])) { + $status['blockers'][] = 'Database replication thread restart failed: ' . $repair['message']; + $status['blockers'] = array_values(array_unique(array_filter($status['blockers']))); + $status['status'] = self::replicationHealthStatus( + $reachable, + (string)($host['role'] ?? ''), + (float)$status['replication_percent'], + $status['blockers'] + ); + } + $status['raw']['replication_thread_repair'] = $repair; + } + + return $status; + } + + private function shouldRepairDatabaseReplicationAccess(array $host, array $status): bool + { + if (!empty($host['skip_replication_access_repair']) + || !empty($host['test_connectivity_only']) + || (string)($host['role'] ?? '') === 'primary') { + return false; + } + + if ($this->databaseDeniedAccountHostsFromStatus($status) === []) { + return false; + } + + $hostCredentials = $this->credentials($host); + $primary = $this->primaryHost(self::KIND_DATABASE); + $primaryCredentials = $primary !== null ? $this->credentials($primary) : []; + + return (($hostCredentials['replication_username'] ?? '') !== '' && ($hostCredentials['replication_password'] ?? '') !== '') + || (($primaryCredentials['replication_username'] ?? '') !== '' && ($primaryCredentials['replication_password'] ?? '') !== ''); + } + + private function repairDatabaseReplicationAccess(array $host, array $status): array + { + $deniedHosts = $this->databaseDeniedAccountHostsFromStatus($status); + if ($deniedHosts === []) { + return [ + 'ok' => false, + 'skipped' => true, + 'message' => 'No denied replication account host was detected.', + ]; + } + + $primary = $this->primaryHost(self::KIND_DATABASE); + if ($primary === null) { + return [ + 'ok' => false, + 'message' => 'No database primary is registered.', + ]; + } + + $primaryCredentials = $this->credentials($primary); + $hostCredentials = $this->credentials($host); + $replicationUser = $hostCredentials['replication_username'] + ?: ($primaryCredentials['replication_username'] ?? ''); + $replicationPassword = $hostCredentials['replication_password'] + ?: ($primaryCredentials['replication_password'] ?? ''); + + if ($replicationUser === '' || $replicationPassword === '') { + return [ + 'ok' => false, + 'skipped' => true, + 'denied_hosts' => $deniedHosts, + 'message' => 'Replication credentials are not available for automatic grant repair.', + ]; + } + + try { + $grantHosts = $this->databaseReplicationGrantHosts($host, $status); + $this->ensureDatabaseReplicationUser($primary, $replicationUser, $replicationPassword, $grantHosts); + + $target = $this->databaseConnection($host, true); + try { + $this->refreshDatabaseReplicationConnection( + $target, + $this->databaseServerStatus($target), + $primary, + $replicationUser, + $replicationPassword + ); + } finally { + $target->close(); + } + + return [ + 'ok' => true, + 'denied_hosts' => $deniedHosts, + 'grant_hosts' => $grantHosts, + ]; + } catch (Throwable $throwable) { + return [ + 'ok' => false, + 'denied_hosts' => $deniedHosts, + 'message' => $throwable->getMessage(), + ]; + } + } + + private function shouldRepairDatabaseReplicationThreads(array $host, array $status): bool + { + if (!empty($host['skip_replication_thread_repair']) + || !empty($host['test_connectivity_only']) + || (string)($host['role'] ?? '') === 'primary') { + return false; + } + + $replicaStatus = $status['raw']['replica_status'] ?? null; + return is_array($replicaStatus) + && $replicaStatus !== [] + && self::databaseOnlyReplicationThreadBlockers($status['blockers'] ?? []); + } + + private static function databaseOnlyReplicationThreadBlockers(array $blockers): bool + { + $blockers = array_values(array_filter(array_map( + static fn(mixed $blocker): string => trim((string)$blocker), + $blockers + ))); + if ($blockers === []) { + return false; + } + + $allowed = [ + 'Database replication IO and SQL threads must both be running.', + 'Database replication IO thread is not running.', + 'Database replication SQL thread is not running.', + ]; + + return array_values(array_diff($blockers, $allowed)) === []; + } + + private function repairDatabaseReplicationThreads(array $host): array + { + try { + $target = $this->databaseConnection($host, true); + try { + $this->restartDatabaseReplicationThreads($target, $this->databaseServerStatus($target)); + } finally { + $target->close(); + } + + return ['ok' => true]; + } catch (Throwable $throwable) { + return [ + 'ok' => false, + 'message' => $throwable->getMessage(), + ]; + } + } + + private function refreshDatabaseReplicationConnection( + mysqli $target, + array $serverStatus, + array $primary, + string $replicationUser, + string $replicationPassword + ): void { + $isMariaDb = self::databaseEngine($serverStatus) === 'mariadb'; + if ($isMariaDb) { + try { + $this->mysqliExec($target, 'STOP SLAVE'); + } catch (Throwable) { + } + $sql = sprintf( + "CHANGE MASTER TO MASTER_HOST = '%s', MASTER_PORT = %d, MASTER_USER = '%s', MASTER_PASSWORD = '%s', MASTER_USE_GTID = slave_pos", + $target->real_escape_string((string)$primary['host']), + (int)$primary['port'], + $target->real_escape_string($replicationUser), + $target->real_escape_string($replicationPassword) + ); + $this->mysqliExec($target, $sql); + } else { + try { + $this->mysqliExec($target, 'STOP REPLICA'); + } catch (Throwable) { + } + $sql = sprintf( + "CHANGE REPLICATION SOURCE TO SOURCE_HOST = '%s', SOURCE_PORT = %d, SOURCE_USER = '%s', SOURCE_PASSWORD = '%s', SOURCE_AUTO_POSITION = 1", + $target->real_escape_string((string)$primary['host']), + (int)$primary['port'], + $target->real_escape_string($replicationUser), + $target->real_escape_string($replicationPassword) + ); + $this->mysqliExec($target, $sql); + } + + $this->restartDatabaseReplicationThreads($target, $serverStatus); + } + + private function restartDatabaseReplicationThreads(mysqli $target, array $serverStatus): void + { + $isMariaDb = self::databaseEngine($serverStatus) === 'mariadb'; + $startStatements = $isMariaDb + ? ['START SLAVE', 'START SLAVE IO_THREAD', 'START SLAVE SQL_THREAD'] + : ['START REPLICA', 'START REPLICA IO_THREAD', 'START REPLICA SQL_THREAD']; + + $lastError = null; + $startedAnyThread = false; + foreach ($startStatements as $index => $statement) { + try { + $this->mysqliExec($target, $statement); + if ($index === 0) { + return; + } + $startedAnyThread = true; + } catch (Throwable $throwable) { + $lastError = $throwable; + } + } + + if ($startedAnyThread) { + return; + } + + if ($lastError !== null) { + throw $lastError; + } + } + + private function testRedisHost(array $host): array + { + $blockers = []; + $raw = []; + $percent = (float)(($host['role'] ?? '') === 'primary' ? 100 : 0); + $reachable = true; + + try { + $client = $this->redisClient($host); + $ping = (string)$client->ping(); + if (stripos($ping, 'PONG') === false && stripos($ping, 'OK') === false) { + $blockers[] = 'Redis PING did not return PONG.'; + } + + $role = $client->executeRaw(['ROLE']); + $info = $this->redisInfo($client); + $raw = [ + 'role' => $role, + 'replication' => $info, + ]; + + try { + $client->executeRaw(['CONFIG', 'GET', 'appendonly']); + } catch (Throwable $throwable) { + $blockers[] = 'Redis ACL must allow CONFIG GET/SET/REWRITE for durable replication changes.'; + } + + if (($host['role'] ?? '') !== 'primary' && empty($host['test_connectivity_only'])) { + $primary = $this->primaryHost(self::KIND_REDIS); + if ($primary === null) { + $blockers[] = 'No Redis primary is registered.'; + } else { + $primaryClient = $this->redisClient($primary); + $primaryInfo = $this->redisInfo($primaryClient); + $percent = self::redisReplicationPercentFromInfo($primaryInfo, $info); + $raw['primary_replication'] = $primaryInfo; + + if (!in_array(strtolower((string)($info['role'] ?? '')), ['slave', 'replica'], true)) { + $blockers[] = 'Redis host is not currently a replica.'; + } + if (strtolower((string)($info['master_link_status'] ?? '')) !== 'up') { + $blockers[] = 'Redis replica link to primary is not up.'; + } + if ($blockers !== [] && $percent >= 100.0) { + $percent = 99.99; + } + } + } + } catch (Throwable $throwable) { + $reachable = false; + $blockers[] = $throwable->getMessage(); + } + + $blockers = array_values(array_unique(array_filter($blockers))); + return [ + 'status' => self::replicationHealthStatus( + $reachable, + (string)($host['role'] ?? ''), + (float)$percent, + $blockers, + empty($host['test_connectivity_only']) + ), + 'replication_percent' => round($percent, 2), + 'lag_seconds' => null, + 'blockers' => $blockers, + 'raw' => $raw, + 'checked_at' => date('c'), + ]; + } + + private function testMinioHost(array $host): array + { + $blockers = []; + $raw = []; + $percent = (float)(($host['role'] ?? '') === 'primary' ? 100 : 0); + $reachable = true; + $connectivityOnly = !empty($host['test_connectivity_only']); + $isPrimary = (string)($host['role'] ?? '') === 'primary'; + $forceStorageScan = !empty($host['force_storage_scan']); + $measureStorage = !$connectivityOnly + && empty($host['skip_storage_scan']) + && $forceStorageScan; + $options = $this->decodeOptions($host); + $buckets = self::normalizeMinioBuckets($options['buckets'] ?? self::MINIO_DEFAULT_BUCKETS); + + try { + $client = $this->minioS3Client($host); + $client->listBuckets(); + + if ($isPrimary) { + $sourceStats = $this->minioBucketStats($client, $buckets, true, 'MinIO source bucket', $measureStorage); + $blockers = array_merge($blockers, $sourceStats['blockers']); + $raw['buckets'] = $sourceStats['buckets']; + $raw['storage'] = [ + 'source_bytes' => $sourceStats['bytes'], + 'source_objects' => $sourceStats['objects'], + 'measured' => $measureStorage, + ]; + } else { + $retentionDaysByBucket = self::minioReplicaRetentionDaysByBucket($buckets); + $targetStats = $this->minioBucketStats( + $client, + $buckets, + !$connectivityOnly, + 'MinIO target bucket', + $measureStorage, + $retentionDaysByBucket + ); + $blockers = array_merge($blockers, $targetStats['blockers']); + $raw['target_buckets'] = $targetStats['buckets']; + $raw['storage'] = [ + 'target_bytes' => $targetStats['bytes'], + 'target_objects' => $targetStats['objects'], + 'target_expired_bytes' => $targetStats['expired_bytes'] ?? 0, + 'target_expired_objects' => $targetStats['expired_objects'] ?? 0, + 'measured' => $measureStorage, + ]; + + if (!$connectivityOnly) { + $primary = $this->primaryHost(self::KIND_MINIO); + if ($primary === null) { + $blockers[] = 'No MinIO primary is registered.'; + } else { + $sourceStats = $this->minioBucketStats( + $this->minioS3Client($primary), + $buckets, + true, + 'MinIO source bucket', + $measureStorage, + $retentionDaysByBucket + ); + $blockers = array_merge($blockers, $sourceStats['blockers']); + $blockers = array_merge($blockers, self::minioBackupRetentionBlockers($targetStats)); + $headroom = (float)($options['space_headroom_percent'] ?? self::MINIO_SPACE_HEADROOM_PERCENT); + $availableBytes = $measureStorage ? $this->minioTargetFreeBytes($host) : null; + $requiredBytes = $measureStorage ? self::minioRequiredFreeBytes((int)$sourceStats['bytes'], $headroom) : null; + $spaceBlockers = $requiredBytes === null ? [] : self::minioSpaceBlockers($availableBytes, $requiredBytes); + $blockers = array_merge($blockers, $spaceBlockers); + $percent = $measureStorage + ? self::minioByteReplicationPercent((int)$sourceStats['bytes'], (int)$targetStats['bytes']) + : self::minioIncompleteProgress(self::lastStatusReplicationPercent($host, 5.0)); + $replicationConfigured = $this->minioReplicationConfigured($primary, $buckets); + $progressStatus = null; + if ($replicationConfigured) { + $progressStatus = $this->minioReplicationProgressStatus($primary, $host, $buckets); + if ($progressStatus !== null) { + $percent = round((float)$progressStatus['replication_percent'], 2); + $raw['progress_source'] = 'minio_replicate_status'; + $raw['replication_status'] = $progressStatus; + } else { + $raw['progress_source'] = 'minio_replicate_status_unavailable'; + } + } + if (!$replicationConfigured) { + $blockers[] = 'MinIO bucket replication is not configured.'; + } elseif (in_array(self::MINIO_BACKUP_BUCKET, $buckets, true) + && !$this->minioBackupReplicaRetentionConfigured($host, self::MINIO_BACKUP_BUCKET)) { + $blockers[] = 'MinIO backup replica retention is not configured for the backups bucket.'; + } elseif (($progressStatus !== null || $measureStorage) && $percent < 100.0) { + $blockers[] = self::MINIO_REPLICA_NOT_CAUGHT_UP_BLOCKER; + } elseif ($progressStatus === null && !$measureStorage) { + $blockers[] = self::MINIO_REPLICA_NOT_CAUGHT_UP_BLOCKER; + } + $raw['source_buckets'] = $sourceStats['buckets']; + $raw['storage'] = array_merge($raw['storage'], [ + 'source_bytes' => $sourceStats['bytes'], + 'source_objects' => $sourceStats['objects'], + 'source_expired_bytes' => $sourceStats['expired_bytes'] ?? 0, + 'source_expired_objects' => $sourceStats['expired_objects'] ?? 0, + 'required_free_bytes' => $requiredBytes, + 'available_free_bytes' => $availableBytes, + 'space_headroom_percent' => $headroom, + 'space_ok' => $measureStorage ? ($availableBytes === null ? null : $spaceBlockers === []) : null, + ]); + } + } + } + } catch (Throwable $throwable) { + $reachable = false; + $blockers[] = $throwable->getMessage(); + } + + $blockers = array_values(array_unique(array_filter($blockers))); + return [ + 'status' => self::replicationHealthStatus( + $reachable, + (string)($host['role'] ?? ''), + (float)$percent, + $blockers, + !$connectivityOnly + ), + 'replication_percent' => round($percent, 2), + 'lag_seconds' => null, + 'blockers' => $blockers, + 'raw' => $raw, + 'checked_at' => date('c'), + ]; + } + + private function minioProgressScanHost(array $host): array + { + if (!$this->minioCanReuseRecentMeasuredStatus($host)) { + return $host; + } + + return array_merge($host, ['skip_storage_scan' => true]); + } + + private function minioCanReuseRecentMeasuredStatus(array $host): bool + { + $lastStatus = self::jsonDecode($host['last_status_json'] ?? null); + if (!is_array($lastStatus)) { + return false; + } + + $storage = $lastStatus['raw']['storage'] ?? null; + if (!is_array($storage) || empty($storage['measured'])) { + return false; + } + + $checkedAt = strtotime((string)($lastStatus['checked_at'] ?? $host['last_checked_at'] ?? '')); + if ($checkedAt === false) { + return false; + } + + return (time() - $checkedAt) < self::MINIO_PROGRESS_SCAN_INTERVAL_SECONDS; + } + + private static function minioOnlyProgressBlockers(array $blockers): bool + { + $blockers = array_values(array_filter(array_map( + static fn(mixed $blocker): string => trim((string)$blocker), + $blockers + ))); + if ($blockers === []) { + return true; + } + + return array_values(array_diff($blockers, [self::MINIO_REPLICA_NOT_CAUGHT_UP_BLOCKER])) === []; + } + + private static function minioProvisionProgressMessage(array $status): string + { + $storage = is_array($status['raw']['storage'] ?? null) ? $status['raw']['storage'] : []; + if (!empty($storage['measured'])) { + $targetBytes = (int)($storage['target_bytes'] ?? 0); + $sourceBytes = (int)($storage['source_bytes'] ?? 0); + if ($sourceBytes > 0) { + return 'MinIO replica is syncing. Copied ' . $targetBytes . ' of ' . $sourceBytes . ' bytes.'; + } + } + + if ((string)($status['raw']['progress_source'] ?? '') === 'minio_replicate_status') { + $percent = round((float)($status['replication_percent'] ?? 0), 2); + return 'MinIO replica is syncing. Replication status reports ' . $percent . '% complete.'; + } + + return 'MinIO replica is syncing in the background. Waiting for the next progress sample.'; + } + + private function minioProvisionStatus(array $primary, array $host): array + { + $status = $this->testMinioHost(array_merge($host, [ + 'test_connectivity_only' => true, + 'skip_storage_scan' => true, + ])); + if ($status['blockers'] !== []) { + return $status; + } + + $primaryOptions = $this->decodeOptions($primary); + $targetOptions = $this->decodeOptions($host); + $buckets = self::normalizeMinioBuckets($targetOptions['buckets'] ?? $primaryOptions['buckets'] ?? self::MINIO_DEFAULT_BUCKETS); + $progress = $this->minioReplicationProgressStatus($primary, $host, $buckets); + + if ($progress === null) { + $percent = self::minioIncompleteProgress(self::lastStatusReplicationPercent($host, 5.0)); + $status['replication_percent'] = $percent; + $status['blockers'] = array_values(array_unique(array_merge( + $status['blockers'], + [self::MINIO_REPLICA_NOT_CAUGHT_UP_BLOCKER] + ))); + $status['raw']['progress_source'] = 'minio_replicate_status_unavailable'; + $status['raw']['replication_status'] = [ + 'available' => false, + 'message' => 'MinIO replication status did not report progress yet.', + ]; + $status['status'] = self::replicationHealthStatus( + true, + (string)($host['role'] ?? ''), + $percent, + $status['blockers'] + ); + + return $status; + } + + $status['replication_percent'] = round((float)$progress['replication_percent'], 2); + $status['blockers'] = array_values(array_unique(array_merge( + $status['blockers'], + $progress['blockers'] ?? [] + ))); + $status['raw']['progress_source'] = 'minio_replicate_status'; + $status['raw']['replication_status'] = $progress; + $status = self::normalizeMinioCaughtUpStatus($status, (string)($host['role'] ?? '')); + $status['status'] = self::replicationHealthStatus( + true, + (string)($host['role'] ?? ''), + (float)$status['replication_percent'], + $status['blockers'] + ); + + return $status; + } + + private static function lastStatusReplicationPercent(array $host, float $default): float + { + $lastStatus = self::jsonDecode($host['last_status_json'] ?? null); + if (is_array($lastStatus) && is_numeric($lastStatus['replication_percent'] ?? null)) { + return round((float)$lastStatus['replication_percent'], 2); + } + + return $default; + } + + private static function normalizeMinioCaughtUpStatus(array $status, string $role): array + { + if ($role === 'primary' || round((float)($status['replication_percent'] ?? 0), 2) < 100.0) { + return $status; + } + + $blockers = array_values(array_filter(array_map( + static fn(mixed $blocker): string => trim((string)$blocker), + $status['blockers'] ?? [] + ))); + if ($blockers === []) { + return $status; + } + + $status['blockers'] = array_values(array_diff($blockers, [self::MINIO_REPLICA_NOT_CAUGHT_UP_BLOCKER])); + return $status; + } + + public static function databasePrerequisiteBlockers(array $status): array + { + if (self::databaseEngine($status) === 'mariadb') { + return self::mariaDbPrerequisiteBlockers($status); + } + + $blockers = []; + if (strtoupper((string)($status['gtid_mode'] ?? '')) !== 'ON') { + $blockers[] = isset($status['gtid_mode']) + ? 'MySQL GTID mode must be ON.' + : 'MySQL GTID mode is unavailable. Managed replication requires Oracle MySQL 8.x with GTID enabled.'; + } + if (!self::mysqlBooleanEnabled($status['log_bin'] ?? null)) { + $blockers[] = isset($status['log_bin']) + ? 'MySQL binary logging must be enabled.' + : 'MySQL binary logging status is unavailable.'; + } + if ((int)($status['server_id'] ?? 0) <= 0) { + $blockers[] = 'MySQL server_id must be configured.'; + } + if (trim((string)($status['server_uuid'] ?? '')) === '') { + $blockers[] = 'MySQL server_uuid must be available. Managed replication requires Oracle MySQL 8.x.'; + } + $serverVersion = (string)($status['server_version'] ?? ''); + if (!str_starts_with($serverVersion, '8.') || stripos($serverVersion, 'mariadb') !== false) { + $blockers[] = $serverVersion !== '' + ? 'Oracle MySQL 8.x is required for managed replication. Current server reports ' . $serverVersion . '.' + : 'Oracle MySQL 8.x is required for managed replication.'; + } + + return $blockers; + } + + public static function missingDatabaseTables(array $sourceTables, array $replicaTables): array + { + $source = array_values(array_unique(array_filter(array_map( + static fn(mixed $table): string => trim((string)$table), + $sourceTables + )))); + $replicaLookup = array_flip(array_values(array_unique(array_filter(array_map( + static fn(mixed $table): string => trim((string)$table), + $replicaTables + ))))); + + return array_values(array_filter( + $source, + static fn(string $table): bool => !isset($replicaLookup[$table]) + )); + } + + private static function mariaDbPrerequisiteBlockers(array $status): array + { + $blockers = []; + if (!self::mysqlBooleanEnabled($status['log_bin'] ?? null)) { + $blockers[] = isset($status['log_bin']) + ? 'MariaDB binary logging must be enabled.' + : 'MariaDB binary logging status is unavailable.'; + } + if ((int)($status['server_id'] ?? 0) <= 0) { + $blockers[] = 'MariaDB server_id must be configured.'; + } + if (!self::mariaDbGtidPositionAvailable($status)) { + $blockers[] = 'MariaDB GTID position must be available.'; + } + + $serverVersion = (string)($status['server_version'] ?? ''); + if (!self::mariaDbVersionSupported($serverVersion)) { + $blockers[] = $serverVersion !== '' + ? 'MariaDB 10.6 or newer is required for managed replication. Current server reports ' . $serverVersion . '.' + : 'MariaDB 10.6 or newer is required for managed replication.'; + } + + return $blockers; + } + + private static function databaseEngine(array $status): string + { + return stripos((string)($status['server_version'] ?? ''), 'mariadb') !== false ? 'mariadb' : 'mysql'; + } + + private static function databaseEngineKnown(array $status): bool + { + return trim((string)($status['server_version'] ?? '')) !== ''; + } + + private static function databaseGtidPosition(array $status): string + { + if (self::databaseEngine($status) === 'mariadb') { + return trim((string)($status['gtid_binlog_pos'] ?? $status['gtid_current_pos'] ?? $status['gtid_slave_pos'] ?? '')); + } + + return trim((string)($status['gtid_executed'] ?? '')); + } + + private static function mariaDbGtidPositionAvailable(array $status): bool + { + foreach (['gtid_binlog_pos', 'gtid_current_pos', 'gtid_slave_pos'] as $key) { + if (array_key_exists($key, $status) && $status[$key] !== null) { + return true; + } + } + + return false; + } + + private static function mariaDbVersionSupported(string $serverVersion): bool + { + if (!preg_match('/(\d+)\.(\d+)/', $serverVersion, $matches)) { + return false; + } + + $major = (int)$matches[1]; + $minor = (int)$matches[2]; + return $major > 10 || ($major === 10 && $minor >= 6); + } + + private static function mysqlBooleanEnabled(mixed $value): bool + { + $normalized = strtoupper(trim((string)$value)); + return in_array($normalized, ['1', 'ON', 'YES', 'TRUE'], true); + } + + private function databaseServerStatus(mysqli $connection): array + { + $row = $this->mysqliSelectOne($connection, "SELECT VERSION() AS server_version"); + $variables = $connection->query( + "SHOW GLOBAL VARIABLES WHERE Variable_name IN ( + 'gtid_mode', + 'log_bin', + 'server_id', + 'server_uuid', + 'read_only', + 'super_read_only', + 'gtid_executed', + 'gtid_binlog_pos', + 'gtid_current_pos', + 'gtid_slave_pos', + 'gtid_strict_mode' + )" + ); + if ($variables !== false) { + while ($variable = $variables->fetch_assoc()) { + $name = strtolower((string)($variable['Variable_name'] ?? '')); + if ($name !== '') { + $row[$name] = $variable['Value'] ?? null; + } + } + } + + $plugin = $this->mysqliSelectOne( + $connection, + "SELECT PLUGIN_STATUS AS plugin_status FROM information_schema.PLUGINS WHERE PLUGIN_NAME = 'clone' LIMIT 1" + ); + $row['clone_plugin_active'] = strtoupper((string)($plugin['plugin_status'] ?? '')) === 'ACTIVE'; + + return $row; + } + + private function showReplicaStatus(mysqli $connection): array + { + try { + $status = $this->mysqliSelectOne($connection, 'SHOW REPLICA STATUS'); + if ($status !== []) { + return $status; + } + } catch (Throwable) { + } + + return $this->mysqliSelectOne($connection, 'SHOW SLAVE STATUS'); + } + + private function databaseConnection(array $host, bool $admin = false, bool $connectWithoutDatabase = false): mysqli + { + $credentials = $this->credentials($host); + $username = $admin && $credentials['admin_username'] !== '' + ? $credentials['admin_username'] + : $credentials['username']; + $password = $admin && $credentials['admin_password'] !== '' + ? $credentials['admin_password'] + : $credentials['password']; + + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + $connection = new mysqli( + (string)$host['host'], + $username, + $password, + $connectWithoutDatabase ? '' : (string)($host['database_name'] ?? ''), + (int)$host['port'] + ); + $connection->set_charset('utf8mb4'); + return $connection; + } + + private function databaseReplicaSeedBlockers(?array $primary, array $host, mysqli $target): array + { + if ($primary === null) { + return ['No database primary is registered.']; + } + + $primaryDatabase = trim((string)($primary['database_name'] ?? '')); + $targetDatabase = trim((string)($host['database_name'] ?? '')); + if ($primaryDatabase === '' || $targetDatabase === '') { + return []; + } + + $primaryConnection = $this->databaseConnection($primary, true); + try { + $primaryTables = $this->databaseTableNames($primaryConnection, $primaryDatabase); + $targetTables = $this->databaseTableNames($target, $targetDatabase); + } finally { + $primaryConnection->close(); + } + + if ($primaryTables === []) { + return []; + } + + $missingTables = self::missingDatabaseTables($primaryTables, $targetTables); + if ($missingTables === []) { + $schemaOnlyTablesWithRows = $this->databaseSchemaOnlyTablesWithRows($target, $targetDatabase); + if ($schemaOnlyTablesWithRows === []) { + return []; + } + + return [self::schemaOnlyTablesContainRowsBlocker($targetDatabase, $schemaOnlyTablesWithRows)]; + } + + return [self::missingDatabaseTablesBlocker($targetDatabase, $missingTables)]; + } + + private function advanceMariaDbReplicaSeed(int $operationId, array $primary, array $host, mysqli $target): array + { + $owner = 'replication-seed:' . $operationId; + $context = $this->operationContext($operationId); + $freezeState = application_write_freeze::state(); + if (($context['phase'] ?? '') !== '' && ($freezeState['owner'] ?? null) !== $owner) { + $context = []; + } + if (self::mariaDbSeedContextRequiresFilterReset($context)) { + $context = []; + } + application_write_freeze::freeze('MariaDB replica seed is copying data.', $owner, self::MARIADB_SEED_FREEZE_TTL_SECONDS); + + $source = $this->databaseConnection($primary, true); + $deadline = microtime(true) + self::MARIADB_SEED_STEP_SECONDS; + + try { + $this->mysqliExec($target, 'SET SESSION FOREIGN_KEY_CHECKS = 0'); + $this->mysqliExec($target, 'SET SESSION UNIQUE_CHECKS = 0'); + + if (($context['phase'] ?? '') === '') { + $context = $this->initializeMariaDbSeedContext($source, $target, $primary, $host); + } + + while (microtime(true) < $deadline && ($context['phase'] ?? '') !== 'complete') { + if (($context['phase'] ?? '') === 'schema') { + $context = $this->advanceMariaDbSeedSchema($source, $target, $context); + continue; + } + + if (($context['phase'] ?? '') === 'copy') { + $context = $this->advanceMariaDbSeedRows($source, $target, $context); + continue; + } + + break; + } + + $progress = self::mariaDbSeedProgress($context); + $message = self::mariaDbSeedMessage($context); + $this->updateOperationProgress($operationId, $progress, $message, $context); + + if (($context['phase'] ?? '') === 'complete') { + application_write_freeze::unfreeze($owner); + return [ + 'running' => false, + 'message' => 'MariaDB replica seed completed.', + 'status' => [ + 'status' => 'provisioning', + 'replication_percent' => 90.0, + 'lag_seconds' => null, + 'blockers' => [], + 'raw' => ['seed' => $context], + 'checked_at' => date('c'), + ], + ]; + } + + return [ + 'running' => true, + 'message' => $message, + 'status' => [ + 'status' => 'provisioning', + 'replication_percent' => $progress, + 'lag_seconds' => null, + 'blockers' => ['MariaDB replica seed is running.'], + 'raw' => ['seed' => $context], + 'checked_at' => date('c'), + ], + ]; + } catch (Throwable $throwable) { + application_write_freeze::unfreeze($owner); + throw $throwable; + } finally { + $source->close(); + } + } + + private function initializeMariaDbSeedContext(mysqli $source, mysqli $target, array $primary, array $host): array + { + $primaryDatabase = trim((string)($primary['database_name'] ?? '')); + $targetDatabase = trim((string)($host['database_name'] ?? '')); + if ($primaryDatabase === '' || $targetDatabase === '') { + throw new RuntimeException('Database names are required before a MariaDB replica can be seeded.'); + } + + $sourceGtid = $this->mariaDbCurrentGtid($source); + $this->prepareMariaDbReplicaTarget($target, $source, $primaryDatabase, $targetDatabase); + + $tables = []; + foreach ($this->databaseTableNames($source, $primaryDatabase) as $tableName) { + $skipData = self::databaseReplicaSeedSkipsTableData($tableName); + $tables[] = [ + 'name' => $tableName, + 'rows' => $skipData ? 0 : $this->estimatedDatabaseTableRows($source, $primaryDatabase, $tableName), + 'copied' => 0, + 'schema_created' => false, + 'skip_data' => $skipData, + 'skip_reason' => $skipData ? 'excluded from managed replication' : '', + ]; + } + + return [ + 'phase' => 'schema', + 'source_database' => $primaryDatabase, + 'target_database' => $targetDatabase, + 'source_gtid' => $sourceGtid, + 'schema_index' => 0, + 'copy_index' => 0, + 'tables' => $tables, + 'started_at' => date('c'), + 'updated_at' => date('c'), + ]; + } + + private function advanceMariaDbSeedSchema(mysqli $source, mysqli $target, array $context): array + { + $tables = $context['tables'] ?? []; + $index = (int)($context['schema_index'] ?? 0); + if (!isset($tables[$index])) { + $context['phase'] = 'copy'; + $context['copy_index'] = 0; + $context['updated_at'] = date('c'); + return $context; + } + + $table = $tables[$index]; + $this->createMariaDbReplicaTable( + $source, + $target, + (string)$context['source_database'], + (string)$context['target_database'], + (string)$table['name'] + ); + + $context['tables'][$index]['schema_created'] = true; + $context['schema_index'] = $index + 1; + $context['updated_at'] = date('c'); + return $context; + } + + private function advanceMariaDbSeedRows(mysqli $source, mysqli $target, array $context): array + { + $tables = $context['tables'] ?? []; + $index = (int)($context['copy_index'] ?? 0); + if (!isset($tables[$index])) { + if (trim((string)($context['source_gtid'] ?? '')) !== '') { + $this->mysqliExec($target, 'SET GLOBAL gtid_slave_pos = ' . self::sqlString($target, (string)$context['source_gtid'])); + } + $this->mysqliExec($target, 'SET SESSION FOREIGN_KEY_CHECKS = 1'); + $this->mysqliExec($target, 'SET SESSION UNIQUE_CHECKS = 1'); + $context['phase'] = 'complete'; + $context['updated_at'] = date('c'); + $context['completed_at'] = date('c'); + return $context; + } + + $tableName = (string)($tables[$index]['name'] ?? ''); + $copied = (int)($tables[$index]['copied'] ?? 0); + if (!empty($tables[$index]['skip_data'])) { + $context['tables'][$index]['copied'] = (int)($tables[$index]['rows'] ?? 0); + $context['copy_index'] = $index + 1; + $context['updated_at'] = date('c'); + return $context; + } + + $copiedNow = $this->copyMariaDbReplicaTableRowsChunk( + $source, + $target, + (string)$context['source_database'], + (string)$context['target_database'], + $tableName, + $copied, + self::MARIADB_SEED_BATCH_ROWS + ); + + $context['tables'][$index]['copied'] = $copied + $copiedNow; + if ($copiedNow < self::MARIADB_SEED_BATCH_ROWS) { + $context['copy_index'] = $index + 1; + } + $context['updated_at'] = date('c'); + return $context; + } + + private static function mariaDbSeedProgress(array $context): float + { + $tables = is_array($context['tables'] ?? null) ? $context['tables'] : []; + if (($context['phase'] ?? '') === 'complete') { + return 90.0; + } + if ($tables === []) { + return 5.0; + } + + $schemaCount = count($tables); + $schemaDone = min($schemaCount, (int)($context['schema_index'] ?? 0)); + $schemaProgress = $schemaCount > 0 ? ($schemaDone / $schemaCount) * 20.0 : 20.0; + + $totalRows = 0; + $copiedRows = 0; + foreach ($tables as $table) { + if (!empty($table['skip_data'])) { + continue; + } + $rows = max(1, (int)($table['rows'] ?? 0)); + $totalRows += $rows; + $copiedRows += min($rows, (int)($table['copied'] ?? 0)); + } + $copyProgress = $totalRows > 0 ? ($copiedRows / $totalRows) * 65.0 : 0.0; + + return round(min(89.0, 5.0 + $schemaProgress + $copyProgress), 2); + } + + private static function mariaDbSeedMessage(array $context): string + { + $tables = is_array($context['tables'] ?? null) ? $context['tables'] : []; + if (($context['phase'] ?? '') === 'schema') { + return 'Creating replica schema ' . min(count($tables), (int)($context['schema_index'] ?? 0)) . ' of ' . count($tables) . '.'; + } + if (($context['phase'] ?? '') === 'copy') { + $index = (int)($context['copy_index'] ?? 0); + $table = $tables[$index]['name'] ?? 'table data'; + if (!empty($tables[$index]['skip_data'])) { + return 'Skipping replica data for ' . $table . '.'; + } + return 'Copying replica data for ' . $table . '.'; + } + if (($context['phase'] ?? '') === 'complete') { + return 'Replica seed completed.'; + } + return 'Preparing replica seed.'; + } + + private function seedMariaDbReplicaFromPrimary(array $primary, array $host, mysqli $target): void + { + $primaryDatabase = trim((string)($primary['database_name'] ?? '')); + $targetDatabase = trim((string)($host['database_name'] ?? '')); + if ($primaryDatabase === '' || $targetDatabase === '') { + throw new RuntimeException('Database names are required before a MariaDB replica can be seeded.'); + } + + $source = $this->databaseConnection($primary, true); + $readLockAcquired = false; + $transactionStarted = false; + + try { + $source->query('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ'); + $source->query('FLUSH TABLES WITH READ LOCK'); + $readLockAcquired = true; + $source->query('START TRANSACTION WITH CONSISTENT SNAPSHOT'); + $transactionStarted = true; + $sourceGtid = $this->mariaDbCurrentGtid($source); + $source->query('UNLOCK TABLES'); + $readLockAcquired = false; + + $this->prepareMariaDbReplicaTarget($target, $source, $primaryDatabase, $targetDatabase); + foreach ($this->databaseTableNames($source, $primaryDatabase) as $tableName) { + $this->createMariaDbReplicaTable($source, $target, $primaryDatabase, $targetDatabase, $tableName); + $this->copyMariaDbReplicaTableRows($source, $target, $primaryDatabase, $targetDatabase, $tableName); + } + + if ($sourceGtid !== '') { + $this->mysqliExec($target, 'SET GLOBAL gtid_slave_pos = ' . self::sqlString($target, $sourceGtid)); + } + $this->mysqliExec($target, 'SET SESSION FOREIGN_KEY_CHECKS = 1'); + + $source->query('COMMIT'); + $transactionStarted = false; + } catch (Throwable $throwable) { + if ($readLockAcquired) { + try { + $source->query('UNLOCK TABLES'); + } catch (Throwable) { + } + } + if ($transactionStarted) { + try { + $source->query('ROLLBACK'); + } catch (Throwable) { + } + } + throw new RuntimeException('MariaDB replica seed failed: ' . $throwable->getMessage(), 0, $throwable); + } finally { + $source->close(); + } + } + + private function mariaDbCurrentGtid(mysqli $source): string + { + foreach (['gtid_binlog_pos', 'gtid_current_pos'] as $variable) { + $row = $this->mysqliSelectOne($source, "SELECT @@GLOBAL.$variable AS value"); + $value = trim((string)($row['value'] ?? '')); + if ($value !== '') { + return $value; + } + } + + return ''; + } + + private function prepareMariaDbReplicaTarget(mysqli $target, mysqli $source, string $primaryDatabase, string $targetDatabase): void + { + foreach (['STOP SLAVE', 'RESET SLAVE ALL'] as $statement) { + try { + $this->mysqliExec($target, $statement); + } catch (Throwable) { + } + } + + $this->mysqliExec($target, 'SET SESSION FOREIGN_KEY_CHECKS = 0'); + $this->mysqliExec($target, 'DROP DATABASE IF EXISTS ' . self::quoteIdentifier($targetDatabase)); + $this->mysqliExec($target, $this->createDatabaseSql($source, $primaryDatabase, $targetDatabase)); + $this->mysqliExec($target, 'USE ' . self::quoteIdentifier($targetDatabase)); + + try { + $this->mysqliExec($target, 'RESET MASTER'); + } catch (Throwable) { + } + try { + $this->mysqliExec($target, "SET GLOBAL gtid_slave_pos = ''"); + } catch (Throwable) { + } + } + + private function createDatabaseSql(mysqli $source, string $primaryDatabase, string $targetDatabase): string + { + $stmt = $source->prepare( + 'SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME + FROM information_schema.SCHEMATA + WHERE SCHEMA_NAME = ? + LIMIT 1' + ); + if ($stmt === false) { + throw new RuntimeException('Could not prepare database schema lookup.'); + } + + $stmt->bind_param('s', $primaryDatabase); + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result ? $result->fetch_assoc() : null; + $stmt->close(); + + $charset = preg_replace('/[^a-zA-Z0-9_]/', '', (string)($row['DEFAULT_CHARACTER_SET_NAME'] ?? 'utf8mb4')) ?: 'utf8mb4'; + $collation = preg_replace('/[^a-zA-Z0-9_]/', '', (string)($row['DEFAULT_COLLATION_NAME'] ?? 'utf8mb4_unicode_ci')) ?: 'utf8mb4_unicode_ci'; + + return 'CREATE DATABASE ' . self::quoteIdentifier($targetDatabase) + . ' CHARACTER SET ' . $charset + . ' COLLATE ' . $collation; + } + + private function createMariaDbReplicaTable(mysqli $source, mysqli $target, string $primaryDatabase, string $targetDatabase, string $tableName): void + { + $create = $this->mysqliSelectOne( + $source, + 'SHOW CREATE TABLE ' . self::quoteIdentifier($primaryDatabase) . '.' . self::quoteIdentifier($tableName) + ); + $createSql = (string)($create['Create Table'] ?? ''); + if ($createSql === '') { + throw new RuntimeException('Could not read CREATE TABLE for ' . $primaryDatabase . '.' . $tableName . '.'); + } + + $this->mysqliExec($target, 'USE ' . self::quoteIdentifier($targetDatabase)); + $this->mysqliExec($target, $createSql); + } + + private static function mariaDbSeedContextRequiresFilterReset(array $context): bool + { + if (($context['phase'] ?? '') === '' || ($context['phase'] ?? '') === 'complete') { + return false; + } + + foreach (($context['tables'] ?? []) as $table) { + if (self::databaseReplicaSeedSkipsTableData((string)($table['name'] ?? '')) + && empty($table['skip_data'])) { + return true; + } + } + + return false; + } + + private function copyMariaDbReplicaTableRows(mysqli $source, mysqli $target, string $primaryDatabase, string $targetDatabase, string $tableName): void + { + if (self::databaseReplicaSeedSkipsTableData($tableName)) { + return; + } + + $offset = 0; + do { + $copied = $this->copyMariaDbReplicaTableRowsChunk( + $source, + $target, + $primaryDatabase, + $targetDatabase, + $tableName, + $offset, + self::MARIADB_SEED_BATCH_ROWS + ); + $offset += $copied; + } while ($copied >= self::MARIADB_SEED_BATCH_ROWS); + } + + private function copyMariaDbReplicaTableRowsChunk( + mysqli $source, + mysqli $target, + string $primaryDatabase, + string $targetDatabase, + string $tableName, + int $offset, + int $limit + ): int { + $columnNames = $this->databaseWritableColumnNames($source, $primaryDatabase, $tableName); + if ($columnNames === []) { + return 0; + } + + $quotedColumns = array_map(static fn(string $column): string => self::quoteIdentifier($column), $columnNames); + $primaryKeyColumns = $this->databasePrimaryKeyColumnNames($source, $primaryDatabase, $tableName); + $orderSql = $primaryKeyColumns !== [] + ? ' ORDER BY ' . implode(', ', array_map(static fn(string $column): string => self::quoteIdentifier($column), $primaryKeyColumns)) + : ''; + $result = $source->query( + 'SELECT ' . implode(', ', $quotedColumns) + . ' FROM ' . self::quoteIdentifier($primaryDatabase) . '.' . self::quoteIdentifier($tableName) + . $orderSql + . ' LIMIT ' . max(0, $offset) . ', ' . max(1, $limit), + MYSQLI_USE_RESULT + ); + if ($result === false) { + throw new RuntimeException('Could not read rows from ' . $primaryDatabase . '.' . $tableName . '.'); + } + + $fields = $result->fetch_fields(); + $insertPrefix = 'INSERT INTO ' . self::quoteIdentifier($targetDatabase) . '.' . self::quoteIdentifier($tableName) + . ' (' . implode(', ', $quotedColumns) . ') VALUES '; + $rows = []; + $batchSize = 200; + $copied = 0; + + try { + $target->begin_transaction(); + while (true) { + $row = $result->fetch_assoc(); + if (!is_array($row)) { + break; + } + + $values = []; + foreach ($fields as $field) { + $value = $row[$field->name] ?? null; + $values[] = $value === null ? 'NULL' : self::sqlString($target, (string)$value); + } + $rows[] = '(' . implode(', ', $values) . ')'; + $copied++; + + if (count($rows) >= $batchSize) { + $this->mysqliExec($target, $insertPrefix . implode(', ', $rows)); + $rows = []; + } + } + + if ($rows !== []) { + $this->mysqliExec($target, $insertPrefix . implode(', ', $rows)); + } + $target->commit(); + } catch (Throwable $throwable) { + try { + $target->rollback(); + } catch (Throwable) { + } + throw $throwable; + } finally { + $result->free(); + } + + return $copied; + } + + private function estimatedDatabaseTableRows(mysqli $connection, string $database, string $tableName): int + { + $stmt = $connection->prepare( + "SELECT TABLE_ROWS + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND TABLE_TYPE = 'BASE TABLE' + LIMIT 1" + ); + if ($stmt === false) { + return 1; + } + + $stmt->bind_param('ss', $database, $tableName); + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result ? $result->fetch_assoc() : null; + $stmt->close(); + + return max(1, (int)($row['TABLE_ROWS'] ?? 1)); + } + + private static function databaseReplicaSeedSkipsTableData(string $tableName): bool + { + return in_array(strtolower($tableName), self::MARIADB_SCHEMA_ONLY_TABLES, true); + } + + private function databaseWritableColumnNames(mysqli $connection, string $database, string $tableName): array + { + $stmt = $connection->prepare( + "SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = ? + AND TABLE_NAME = ? + AND EXTRA NOT LIKE '%GENERATED%' + ORDER BY ORDINAL_POSITION" + ); + if ($stmt === false) { + throw new RuntimeException('Could not prepare table column lookup.'); + } + + $stmt->bind_param('ss', $database, $tableName); + $stmt->execute(); + $result = $stmt->get_result(); + $rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + $stmt->close(); + + return array_values(array_filter(array_map( + static fn(array $row): string => (string)($row['COLUMN_NAME'] ?? ''), + $rows + ))); + } + + private function databasePrimaryKeyColumnNames(mysqli $connection, string $database, string $tableName): array + { + $stmt = $connection->prepare( + "SELECT COLUMN_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = ? + AND TABLE_NAME = ? + AND CONSTRAINT_NAME = 'PRIMARY' + ORDER BY ORDINAL_POSITION" + ); + if ($stmt === false) { + return []; + } + + $stmt->bind_param('ss', $database, $tableName); + $stmt->execute(); + $result = $stmt->get_result(); + $rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + $stmt->close(); + + return array_values(array_filter(array_map( + static fn(array $row): string => (string)($row['COLUMN_NAME'] ?? ''), + $rows + ))); + } + + private function databaseTableNames(mysqli $connection, string $database): array + { + $stmt = $connection->prepare( + "SELECT TABLE_NAME FROM information_schema.TABLES + WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE' + ORDER BY TABLE_NAME" + ); + if ($stmt === false) { + throw new RuntimeException('Could not prepare database table comparison query.'); + } + + $stmt->bind_param('s', $database); + $stmt->execute(); + $result = $stmt->get_result(); + $rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + $stmt->close(); + + return array_values(array_map( + static fn(array $row): string => (string)($row['TABLE_NAME'] ?? ''), + $rows + )); + } + + private function databaseSchemaOnlyTablesWithRows(mysqli $connection, string $database): array + { + $existingTables = []; + foreach ($this->databaseTableNames($connection, $database) as $tableName) { + $existingTables[strtolower($tableName)] = $tableName; + } + + $tablesWithRows = []; + foreach (self::MARIADB_SCHEMA_ONLY_TABLES as $schemaOnlyTable) { + $actualTable = $existingTables[strtolower($schemaOnlyTable)] ?? null; + if ($actualTable === null) { + continue; + } + + $row = $this->mysqliSelectOne( + $connection, + 'SELECT 1 AS has_rows FROM ' . self::quoteIdentifier($database) . '.' . self::quoteIdentifier($actualTable) . ' LIMIT 1' + ); + if (($row['has_rows'] ?? null) !== null) { + $tablesWithRows[] = $actualTable; + } + } + + return $tablesWithRows; + } + + private static function missingDatabaseTablesBlocker(string $database, array $missingTables): string + { + $shownTables = array_slice($missingTables, 0, 3); + $qualifiedTables = array_map( + static fn(string $table): string => $database . '.' . $table, + $shownTables + ); + $tableWord = count($missingTables) === 1 ? 'table' : 'tables'; + $sample = $qualifiedTables !== [] ? ': ' . implode(', ', $qualifiedTables) : ''; + + return 'Replica seed is incomplete. Missing ' . count($missingTables) . ' database ' . $tableWord . ' on replica' . $sample . '.'; + } + + private static function schemaOnlyTablesContainRowsBlocker(string $database, array $tables): string + { + $shownTables = array_slice($tables, 0, 3); + $qualifiedTables = array_map( + static fn(string $table): string => $database . '.' . $table, + $shownTables + ); + $sample = $qualifiedTables !== [] ? ': ' . implode(', ', $qualifiedTables) : ''; + + return 'Replica seed includes data for schema-only tables' . $sample . '. Re-run provisioning to rebuild the replica without schema-only table data.'; + } + + private function minioS3Client(array $host): S3Client + { + $credentials = $this->credentials($host); + return new S3Client([ + 'version' => 'latest', + 'region' => 'us-east-1', + 'endpoint' => self::minioEndpoint($host), + 'use_path_style_endpoint' => true, + 'retries' => 0, + 'http' => [ + 'connect_timeout' => self::MINIO_S3_CONNECT_TIMEOUT_SECONDS, + 'timeout' => self::MINIO_S3_REQUEST_TIMEOUT_SECONDS, + ], + 'credentials' => [ + 'key' => $credentials['username'], + 'secret' => $credentials['password'], + ], + ]); + } + + private static function minioObjectLastModifiedTimestamp(mixed $value): ?int + { + if ($value instanceof \DateTimeInterface) { + return $value->getTimestamp(); + } + if (is_numeric($value)) { + return (int)$value; + } + $timestamp = strtotime((string)$value); + return $timestamp === false ? null : $timestamp; + } + + private function minioBucketStats( + S3Client $client, + array $buckets, + bool $requireExists, + string $missingPrefix, + bool $measureObjects = true, + array $retentionDaysByBucket = [] + ): array + { + $stats = [ + 'bytes' => 0, + 'objects' => 0, + 'expired_bytes' => 0, + 'expired_objects' => 0, + 'buckets' => [], + 'blockers' => [], + ]; + + foreach ($buckets as $bucket) { + $retentionDays = isset($retentionDaysByBucket[$bucket]) ? (int)$retentionDaysByBucket[$bucket] : null; + $retentionCutoff = $retentionDays !== null ? time() - ($retentionDays * 86400) : null; + try { + $exists = (bool)$client->doesBucketExist($bucket); + if (!$exists) { + $stats['buckets'][] = [ + 'name' => $bucket, + 'status' => 'missing', + 'bytes' => 0, + 'objects' => 0, + ]; + if ($requireExists) { + $stats['blockers'][] = $missingPrefix . ' ' . $bucket . ' is missing.'; + } + continue; + } + + if (!$measureObjects) { + $stats['buckets'][] = [ + 'name' => $bucket, + 'status' => 'ok', + 'bytes' => null, + 'objects' => null, + 'expired_bytes' => null, + 'expired_objects' => null, + 'measured' => false, + 'retention_days' => $retentionDays, + ]; + continue; + } + + $bucketBytes = 0; + $bucketObjects = 0; + $bucketExpiredBytes = 0; + $bucketExpiredObjects = 0; + $token = null; + do { + $args = ['Bucket' => $bucket]; + if ($token !== null) { + $args['ContinuationToken'] = $token; + } + $result = $client->listObjectsV2($args); + foreach (($result['Contents'] ?? []) as $object) { + $size = (int)($object['Size'] ?? 0); + $lastModified = self::minioObjectLastModifiedTimestamp($object['LastModified'] ?? null); + if ($retentionCutoff !== null && $lastModified !== null && $lastModified < $retentionCutoff) { + $bucketExpiredBytes += $size; + $bucketExpiredObjects++; + continue; + } + + $bucketBytes += $size; + $bucketObjects++; + } + $token = isset($result['NextContinuationToken']) ? (string)$result['NextContinuationToken'] : null; + } while ($token !== null); + + $stats['bytes'] += $bucketBytes; + $stats['objects'] += $bucketObjects; + $stats['expired_bytes'] += $bucketExpiredBytes; + $stats['expired_objects'] += $bucketExpiredObjects; + $stats['buckets'][] = [ + 'name' => $bucket, + 'status' => 'ok', + 'bytes' => $bucketBytes, + 'objects' => $bucketObjects, + 'expired_bytes' => $bucketExpiredBytes, + 'expired_objects' => $bucketExpiredObjects, + 'retention_days' => $retentionDays, + 'retention_cutoff' => $retentionCutoff !== null ? date('c', $retentionCutoff) : null, + ]; + } catch (Throwable $throwable) { + $stats['buckets'][] = [ + 'name' => $bucket, + 'status' => 'down', + 'bytes' => 0, + 'objects' => 0, + 'expired_bytes' => 0, + 'expired_objects' => 0, + 'retention_days' => $retentionDays, + 'error' => $throwable->getMessage(), + ]; + $stats['blockers'][] = $missingPrefix . ' ' . $bucket . ' could not be inspected: ' . $throwable->getMessage(); + } + } + + $stats['blockers'] = array_values(array_unique($stats['blockers'])); + return $stats; + } + + private function configureMinioReplication(array $primary, array $target): void + { + $primaryOptions = $this->decodeOptions($primary); + $targetOptions = $this->decodeOptions($target); + $buckets = self::normalizeMinioBuckets($targetOptions['buckets'] ?? $primaryOptions['buckets'] ?? self::MINIO_DEFAULT_BUCKETS); + $transferLimit = self::minioReplicationTransferLimitFromOptions($targetOptions); + $configDir = $this->createMinioConfigDir(); + + try { + $this->prepareMinioAlias($configDir, 'source', $primary); + $this->prepareMinioAlias($configDir, 'target', $target); + + foreach ($buckets as $index => $bucket) { + $this->runMinioClient($configDir, ['mb', '--with-lock', '--ignore-existing', 'target/' . $bucket]); + $this->runMinioClient($configDir, ['version', 'enable', 'source/' . $bucket]); + $this->runMinioClient($configDir, ['version', 'enable', 'target/' . $bucket]); + + if (self::minioBucketUsesBoundedReplicaRetention($bucket)) { + $this->configureMinioReplicaBackupRetention($target, $bucket); + // Backups are bounded on replicas; avoid bulk seeding large historical objects. + $this->pruneMinioReplicaBackupRetention($target, $bucket); + } + + $this->addMinioReplicationRule($configDir, $target, $bucket, $index + 1, $transferLimit); + } + } finally { + $this->removeDirectory($configDir); + } + } + + private function configureMinioReplicaBackupRetention(array $target, string $bucket): void + { + $client = $this->minioS3Client($target); + $rules = []; + + try { + $current = $client->getBucketLifecycleConfiguration(['Bucket' => $bucket]); + foreach (($current['Rules'] ?? []) as $rule) { + if ((string)($rule['ID'] ?? '') !== self::MINIO_BACKUP_REPLICA_RETENTION_RULE_ID) { + $rules[] = $rule; + } + } + } catch (Throwable $throwable) { + if (!self::minioMissingLifecycleConfiguration($throwable)) { + throw $throwable; + } + } + + $rules[] = self::minioBackupReplicaRetentionLifecycleRule(); + + $client->putBucketLifecycleConfiguration([ + 'Bucket' => $bucket, + 'LifecycleConfiguration' => [ + 'Rules' => $rules, + ], + ]); + } + + private static function minioBackupReplicaRetentionLifecycleRule(): array + { + return [ + 'ID' => self::MINIO_BACKUP_REPLICA_RETENTION_RULE_ID, + 'Status' => 'Enabled', + 'Filter' => ['Prefix' => ''], + 'Expiration' => ['Days' => self::MINIO_BACKUP_REPLICA_RETENTION_DAYS], + 'NoncurrentVersionExpiration' => ['NoncurrentDays' => self::MINIO_BACKUP_REPLICA_RETENTION_DAYS], + 'AbortIncompleteMultipartUpload' => ['DaysAfterInitiation' => 7], + ]; + } + + private static function minioMissingLifecycleConfiguration(Throwable $throwable): bool + { + $message = strtolower($throwable->getMessage()); + return str_contains($message, 'nosuchlifecycleconfiguration') + || str_contains($message, 'lifecycle configuration does not exist') + || str_contains($message, 'the lifecycle configuration does not exist'); + } + + private function minioBackupReplicaRetentionConfigured(array $target, string $bucket): bool + { + try { + $current = $this->minioS3Client($target)->getBucketLifecycleConfiguration(['Bucket' => $bucket]); + } catch (Throwable) { + return false; + } + + foreach (($current['Rules'] ?? []) as $rule) { + if ((string)($rule['ID'] ?? '') !== self::MINIO_BACKUP_REPLICA_RETENTION_RULE_ID) { + continue; + } + if (strtolower((string)($rule['Status'] ?? '')) !== 'enabled') { + return false; + } + + $expirationDays = (int)($rule['Expiration']['Days'] ?? 0); + $noncurrentDays = (int)($rule['NoncurrentVersionExpiration']['NoncurrentDays'] ?? 0); + return $expirationDays > 0 + && $expirationDays <= self::MINIO_BACKUP_REPLICA_RETENTION_DAYS + && $noncurrentDays > 0 + && $noncurrentDays <= self::MINIO_BACKUP_REPLICA_RETENTION_DAYS; + } + + return false; + } + + private function pruneMinioReplicaBackupRetention(array $target, string $bucket): array + { + $client = $this->minioS3Client($target); + $cutoff = time() - (self::MINIO_BACKUP_REPLICA_RETENTION_DAYS * 86400); + $deleted = [ + 'versions' => 0, + 'delete_markers' => 0, + 'cutoff' => date('c', $cutoff), + ]; + + try { + $this->pruneMinioReplicaBackupVersions($client, $bucket, $cutoff, $deleted); + } catch (Throwable $throwable) { + if (!self::minioVersionListingUnsupported($throwable)) { + throw $throwable; + } + $this->pruneMinioReplicaBackupCurrentObjects($client, $bucket, $cutoff, $deleted); + } + + return $deleted; + } + + private static function minioVersionListingUnsupported(Throwable $throwable): bool + { + $message = strtolower($throwable->getMessage()); + return str_contains($message, 'not implemented') + || str_contains($message, 'not supported') + || str_contains($message, 'unsupported') + || str_contains($message, 'listobjectversions'); + } + + private function pruneMinioReplicaBackupVersions(S3Client $client, string $bucket, int $cutoff, array &$deleted): void + { + $keyMarker = null; + $versionIdMarker = null; + do { + $args = ['Bucket' => $bucket]; + if ($keyMarker !== null) { + $args['KeyMarker'] = $keyMarker; + } + if ($versionIdMarker !== null) { + $args['VersionIdMarker'] = $versionIdMarker; + } + + $result = $client->listObjectVersions($args); + $objects = []; + + foreach (($result['Versions'] ?? []) as $version) { + if (self::minioObjectVersionIsOlderThan($version, $cutoff)) { + $objects[] = [ + 'Key' => (string)($version['Key'] ?? ''), + 'VersionId' => (string)($version['VersionId'] ?? ''), + ]; + $deleted['versions']++; + } + } + + foreach (($result['DeleteMarkers'] ?? []) as $marker) { + if (self::minioObjectVersionIsOlderThan($marker, $cutoff)) { + $objects[] = [ + 'Key' => (string)($marker['Key'] ?? ''), + 'VersionId' => (string)($marker['VersionId'] ?? ''), + ]; + $deleted['delete_markers']++; + } + } + + $this->deleteMinioObjectsInBatches($client, $bucket, $objects); + + $keyMarker = isset($result['NextKeyMarker']) ? (string)$result['NextKeyMarker'] : null; + $versionIdMarker = isset($result['NextVersionIdMarker']) ? (string)$result['NextVersionIdMarker'] : null; + } while (!empty($result['IsTruncated'])); + } + + private function pruneMinioReplicaBackupCurrentObjects(S3Client $client, string $bucket, int $cutoff, array &$deleted): void + { + $token = null; + do { + $args = ['Bucket' => $bucket]; + if ($token !== null) { + $args['ContinuationToken'] = $token; + } + + $result = $client->listObjectsV2($args); + $objects = []; + foreach (($result['Contents'] ?? []) as $object) { + if (!self::minioObjectVersionIsOlderThan($object, $cutoff)) { + continue; + } + $objects[] = ['Key' => (string)($object['Key'] ?? '')]; + $deleted['versions']++; + } + + $this->deleteMinioObjectsInBatches($client, $bucket, $objects); + $token = isset($result['NextContinuationToken']) ? (string)$result['NextContinuationToken'] : null; + } while ($token !== null); + } + + private static function minioObjectVersionIsOlderThan(array $object, int $cutoff): bool + { + $key = trim((string)($object['Key'] ?? '')); + if ($key === '') { + return false; + } + + $lastModified = self::minioObjectLastModifiedTimestamp($object['LastModified'] ?? null); + return $lastModified !== null && $lastModified < $cutoff; + } + + private function deleteMinioObjectsInBatches(S3Client $client, string $bucket, array $objects): void + { + foreach (array_chunk($objects, 1000) as $chunk) { + $chunk = array_values(array_filter( + $chunk, + static fn(array $object): bool => trim((string)($object['Key'] ?? '')) !== '' + )); + if ($chunk === []) { + continue; + } + + $client->deleteObjects([ + 'Bucket' => $bucket, + 'Delete' => [ + 'Objects' => $chunk, + 'Quiet' => true, + ], + ]); + } + } + + private function addMinioReplicationRule(string $configDir, array $target, string $bucket, int $priority, string $transferLimit): void + { + try { + $this->runMinioClient($configDir, self::minioReplicationRuleCommand($bucket, $priority, $transferLimit)); + return; + } catch (Throwable $throwable) { + $message = strtolower($throwable->getMessage()); + if (self::minioReplicationRuleAlreadyExists($message)) { + $this->updateMinioReplicationRulesForBucket($configDir, $bucket, $transferLimit); + return; + } + if (!$this->repairMinioTargetBucketObjectLockIfEmpty($configDir, $target, $bucket, $message)) { + throw $throwable; + } + } + + try { + $this->runMinioClient($configDir, self::minioReplicationRuleCommand($bucket, $priority, $transferLimit)); + } catch (Throwable $throwable) { + $message = strtolower($throwable->getMessage()); + if (!self::minioReplicationRuleAlreadyExists($message)) { + throw $throwable; + } + $this->updateMinioReplicationRulesForBucket($configDir, $bucket, $transferLimit); + } + } + + private function updateMinioReplicationRulesForBucket(string $configDir, string $bucket, string $transferLimit): void + { + $result = $this->runMinioClient($configDir, ['replicate', 'ls', '--json', 'source/' . $bucket]); + $ruleIds = self::minioReplicationRuleIdsFromList(self::decodeMinioJsonOutput((string)$result['stdout'])); + foreach ($ruleIds as $ruleId) { + $this->runMinioClient($configDir, array_merge([ + 'replicate', + 'update', + '--id', + $ruleId, + '--replicate', + self::minioReplicationFeatures($bucket), + ], self::minioReplicationTransferLimitArgs($transferLimit), [ + 'source/' . $bucket, + ])); + } + } + + private static function minioReplicationRuleCommand(string $bucket, int $priority, string $transferLimit): array + { + return array_merge([ + 'replicate', + 'add', + '--remote-bucket', + 'target/' . $bucket, + '--replicate', + self::minioReplicationFeatures($bucket), + '--priority', + (string)$priority, + ], self::minioReplicationTransferLimitArgs($transferLimit), [ + 'source/' . $bucket, + ]); + } + + private static function minioReplicationFeatures(string $bucket): string + { + return self::minioBucketUsesBoundedReplicaRetention($bucket) + ? 'delete,delete-marker' + : 'delete,delete-marker,existing-objects'; + } + + private static function minioReplicationRuleIdsFromList(mixed $value): array + { + $ids = []; + self::collectMinioReplicationRuleIds($value, $ids); + return array_values(array_unique(array_filter($ids))); + } + + private static function collectMinioReplicationRuleIds(mixed $value, array &$ids): void + { + if (!is_array($value)) { + return; + } + + foreach ($value as $key => $entry) { + $normalizedKey = strtolower(str_replace(['_', '-'], '', (string)$key)); + if (in_array($normalizedKey, ['id', 'ruleid'], true) && is_scalar($entry)) { + $id = trim((string)$entry); + if ($id !== '') { + $ids[] = $id; + } + continue; + } + + self::collectMinioReplicationRuleIds($entry, $ids); + } + } + + private static function minioReplicationRuleAlreadyExists(string $message): bool + { + return str_contains($message, 'already') + || str_contains($message, 'replication rule exists') + || str_contains($message, 'replication configuration exists'); + } + + private function repairMinioTargetBucketObjectLockIfEmpty(string $configDir, array $target, string $bucket, string $message): bool + { + if (!self::minioObjectLockRequiredError($message)) { + return false; + } + + if ($this->minioBucketHasObjects($target, $bucket)) { + throw new RuntimeException( + 'MinIO target bucket ' . $bucket . ' was created without Object Lock and is not empty. ' + . 'Create a new empty replica bucket with Object Lock enabled, or empty and recreate this bucket before provisioning.' + ); + } + + $this->runMinioClient($configDir, ['rb', 'target/' . $bucket]); + $this->runMinioClient($configDir, ['mb', '--with-lock', 'target/' . $bucket]); + $this->runMinioClient($configDir, ['version', 'enable', 'target/' . $bucket]); + + return true; + } + + private static function minioObjectLockRequiredError(string $message): bool + { + return (str_contains($message, 'object lock') || str_contains($message, 'object locking')) + && str_contains($message, 'destination bucket'); + } + + private function minioBucketHasObjects(array $host, string $bucket): bool + { + $client = $this->minioS3Client($host); + $objects = $client->listObjectsV2([ + 'Bucket' => $bucket, + 'MaxKeys' => 1, + ]); + if (!empty($objects['Contents'])) { + return true; + } + + try { + $versions = $client->listObjectVersions([ + 'Bucket' => $bucket, + 'MaxKeys' => 1, + ]); + return !empty($versions['Versions']) || !empty($versions['DeleteMarkers']); + } catch (Throwable) { + return false; + } + } + + private function minioReplicationConfiguredForHosts(array $primary, array $target): bool + { + $primaryOptions = $this->decodeOptions($primary); + $targetOptions = $this->decodeOptions($target); + $buckets = self::normalizeMinioBuckets($targetOptions['buckets'] ?? $primaryOptions['buckets'] ?? self::MINIO_DEFAULT_BUCKETS); + + if (!$this->minioReplicationConfigured($primary, $buckets)) { + return false; + } + + if (in_array(self::MINIO_BACKUP_BUCKET, $buckets, true) + && !$this->minioBackupReplicaRetentionConfigured($target, self::MINIO_BACKUP_BUCKET)) { + return false; + } + + return true; + } + + private function minioReplicationConfigured(array $primary, array $buckets): bool + { + $configDir = $this->createMinioConfigDir(); + try { + $this->prepareMinioAlias($configDir, 'source', $primary); + foreach ($buckets as $bucket) { + try { + $result = $this->runMinioClient($configDir, ['replicate', 'list', '--json', 'source/' . $bucket]); + } catch (Throwable) { + return false; + } + if (trim((string)$result['stdout']) === '') { + return false; + } + } + + return true; + } finally { + $this->removeDirectory($configDir); + } + } + + private function minioReplicationProgressStatus(array $primary, array $target, array $buckets): ?array + { + $targetOptions = $this->decodeOptions($target); + $transferLimit = self::minioReplicationTransferLimitFromOptions($targetOptions); + $configDir = $this->createMinioConfigDir(); + $bucketProgress = []; + $bucketOutput = []; + $requiredUnavailableBuckets = []; + + try { + $this->prepareMinioAlias($configDir, 'source', $primary); + foreach ($buckets as $bucket) { + $countsTowardCatchUp = self::minioBucketCountsTowardCatchUp((string)$bucket); + try { + // MinIO keeps removed/re-added ARNs in JSON status output. Prefer standard + // output so stale targets do not keep a healthy current target below 100%. + $result = $this->runMinioClient($configDir, array_merge([ + 'replicate', + 'status', + 'source/' . $bucket, + ], self::minioReplicationTransferLimitArgs($transferLimit))); + $stdout = (string)$result['stdout']; + $raw = $stdout; + $progress = self::minioReplicationProgressFromStatusOutput($stdout); + + if ($progress === null) { + $result = $this->runMinioClient($configDir, array_merge([ + 'replicate', + 'status', + '--json', + 'source/' . $bucket, + ], self::minioReplicationTransferLimitArgs($transferLimit))); + $stdout = (string)$result['stdout']; + $decoded = self::decodeMinioJsonOutput($stdout); + $raw = $decoded ?? $stdout; + $progress = self::minioReplicationProgressFromStatusOutput($raw) + ?? self::minioReplicationProgressFromStatusOutput($stdout); + } + + $bucketOutput[$bucket] = [ + 'ok' => true, + 'counts_toward_catch_up' => $countsTowardCatchUp, + 'raw' => $raw, + 'progress' => $progress, + ]; + if ($progress !== null) { + $bucketProgress[$bucket] = $progress; + } elseif ($countsTowardCatchUp) { + $requiredUnavailableBuckets[] = (string)$bucket; + } + } catch (Throwable $throwable) { + $bucketOutput[$bucket] = [ + 'ok' => false, + 'counts_toward_catch_up' => $countsTowardCatchUp, + 'error' => $throwable->getMessage(), + ]; + if ($countsTowardCatchUp) { + $requiredUnavailableBuckets[] = (string)$bucket; + } + } + } + } finally { + $this->removeDirectory($configDir); + } + + $progress = self::minioCatchUpProgressFromBucketStatuses($bucketProgress); + if ($progress === null) { + return null; + } + + $requiredUnavailableBuckets = array_values(array_unique($requiredUnavailableBuckets)); + if ($requiredUnavailableBuckets !== []) { + $progress['replication_percent'] = self::minioIncompleteProgress((float)$progress['replication_percent']); + $progress['blockers'] = array_values(array_unique(array_merge($progress['blockers'] ?? [], [ + 'MinIO replication status is unavailable for bucket(s): ' . implode(', ', $requiredUnavailableBuckets) . '.', + ]))); + $progress['unavailable_required_buckets'] = $requiredUnavailableBuckets; + } + + $progress['buckets'] = $bucketOutput; + $progress['target_endpoint'] = self::minioEndpoint($target); + + return $progress; + } + + public static function minioCatchUpProgressFromBucketStatuses(array $bucketProgress): ?array + { + $requiredProgress = []; + $ignoredBuckets = []; + + foreach ($bucketProgress as $bucket => $progress) { + $bucket = (string)$bucket; + if (self::minioBucketCountsTowardCatchUp($bucket)) { + $requiredProgress[$bucket] = $progress; + continue; + } + + $ignoredBuckets[] = $bucket; + } + + $progress = self::aggregateMinioReplicationProgress($requiredProgress); + if ($progress === null && $ignoredBuckets !== []) { + $progress = [ + 'replication_percent' => 100.0, + 'blockers' => [], + 'basis' => 'bounded_retention_only', + 'stats' => [ + 'completed_bytes' => 0.0, + 'pending_bytes' => 0.0, + 'failed_bytes' => 0.0, + 'total_bytes' => 0.0, + 'completed_count' => 0.0, + 'pending_count' => 0.0, + 'failed_count' => 0.0, + 'total_count' => 0.0, + ], + 'bucket_count' => 0, + ]; + } + + if ($progress === null) { + return null; + } + + $progress['ignored_buckets'] = $ignoredBuckets; + $progress['catch_up_bucket_count'] = count($requiredProgress); + + return $progress; + } + + private static function aggregateMinioReplicationProgress(array $bucketProgress): ?array + { + if ($bucketProgress === []) { + return null; + } + + $stats = [ + 'completed_bytes' => 0.0, + 'pending_bytes' => 0.0, + 'failed_bytes' => 0.0, + 'total_bytes' => 0.0, + 'completed_count' => 0.0, + 'pending_count' => 0.0, + 'failed_count' => 0.0, + 'total_count' => 0.0, + ]; + $percentages = []; + foreach ($bucketProgress as $progress) { + $percentages[] = (float)($progress['replication_percent'] ?? 0); + $progressStats = is_array($progress['stats'] ?? null) ? $progress['stats'] : []; + foreach (array_keys($stats) as $key) { + $stats[$key] += (float)($progressStats[$key] ?? 0); + } + } + + $completedBytes = $stats['completed_bytes']; + $remainingBytes = $stats['pending_bytes'] + $stats['failed_bytes']; + $totalBytes = $stats['total_bytes']; + $completedCount = $stats['completed_count']; + $remainingCount = $stats['pending_count'] + $stats['failed_count']; + $totalCount = $stats['total_count']; + $basis = 'bucket_average'; + + if ($totalBytes > 0.0) { + $percent = ($completedBytes / $totalBytes) * 100; + $basis = 'total_bytes'; + } elseif (($completedBytes + $remainingBytes) > 0.0) { + $percent = ($completedBytes / ($completedBytes + $remainingBytes)) * 100; + $basis = 'byte_balance'; + } elseif ($totalCount > 0.0) { + $percent = ($completedCount / $totalCount) * 100; + $basis = 'total_count'; + } elseif (($completedCount + $remainingCount) > 0.0) { + $percent = ($completedCount / ($completedCount + $remainingCount)) * 100; + $basis = 'count_balance'; + } else { + $percent = array_sum($percentages) / max(1, count($percentages)); + } + + $percent = round(min(100.0, max(0.0, $percent)), 2); + $withinTolerance = $stats['failed_bytes'] <= 0.0 + && $stats['failed_count'] <= 0.0 + && $stats['pending_bytes'] <= self::MINIO_CATCH_UP_PENDING_BYTES_TOLERANCE + && $stats['pending_count'] <= self::MINIO_CATCH_UP_PENDING_OBJECTS_TOLERANCE; + if ($percent < 100.0 && $withinTolerance) { + $percent = 100.0; + $basis .= '_within_live_tolerance'; + } + + return [ + 'replication_percent' => $percent, + 'blockers' => $percent < 100.0 ? [self::MINIO_REPLICA_NOT_CAUGHT_UP_BLOCKER] : [], + 'basis' => $basis, + 'stats' => $stats, + 'bucket_count' => count($bucketProgress), + 'live_tolerance' => [ + 'pending_bytes' => self::MINIO_CATCH_UP_PENDING_BYTES_TOLERANCE, + 'pending_objects' => self::MINIO_CATCH_UP_PENDING_OBJECTS_TOLERANCE, + 'within_tolerance' => $withinTolerance, + ], + ]; + } + + private function minioTargetFreeBytes(array $host): ?int + { + $configDir = $this->createMinioConfigDir(); + try { + $this->prepareMinioAlias($configDir, 'target', $host); + $result = $this->runMinioClient($configDir, ['admin', 'info', '--json', 'target']); + $decoded = self::decodeMinioJsonOutput((string)$result['stdout']); + return self::minioAvailableBytesFromAdminInfo($decoded); + } catch (Throwable) { + return null; + } finally { + $this->removeDirectory($configDir); + } + } + + private function prepareMinioAlias(string $configDir, string $alias, array $host): void + { + $credentials = $this->credentials($host); + $this->runMinioClient($configDir, [ + 'alias', + 'set', + $alias, + self::minioEndpoint($host), + $credentials['username'], + $credentials['password'], + ]); + } + + private function runMinioClient(string $configDir, array $arguments): array + { + $binary = self::minioClientBinary(); + if ($binary === null) { + throw new RuntimeException('MinIO Client (mc) is not available in the PHP runtime. Install mc, set MINIO_MC_BINARY, or enable MINIO_MC_AUTO_INSTALL.'); + } + + $command = array_merge([$binary, '--config-dir', $configDir], array_map('strval', $arguments)); + $timeoutSeconds = self::minioClientCommandTimeoutSeconds(); + $pipes = []; + $process = @proc_open($command, [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes); + if (!is_resource($process)) { + throw new RuntimeException('MinIO Client (mc) is not available.'); + } + + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $stdout = ''; + $stderr = ''; + $exitCode = null; + $timedOut = false; + $deadline = microtime(true) + $timeoutSeconds; + + while (true) { + $stdout .= (string)stream_get_contents($pipes[1]); + $stderr .= (string)stream_get_contents($pipes[2]); + + $status = proc_get_status($process); + if (empty($status['running'])) { + $exitCode = (int)($status['exitcode'] ?? -1); + break; + } + + if (microtime(true) >= $deadline) { + $timedOut = true; + proc_terminate($process); + usleep(100000); + $status = proc_get_status($process); + if (!empty($status['running'])) { + proc_terminate($process, 9); + } + break; + } + + usleep(50000); + } + + $stdout .= (string)stream_get_contents($pipes[1]); + fclose($pipes[1]); + $stderr .= (string)stream_get_contents($pipes[2]); + fclose($pipes[2]); + + if ($timedOut) { + throw new RuntimeException( + 'MinIO Client command timed out after ' . $timeoutSeconds . ' seconds: ' + . self::minioClientCommandLabel($arguments) + ); + } + + $closeCode = proc_close($process); + if ($exitCode === null || $exitCode < 0) { + $exitCode = $closeCode; + } + + if ($exitCode !== 0) { + $message = trim((string)$stderr) ?: trim((string)$stdout) ?: 'MinIO Client command failed.'; + throw new RuntimeException($message); + } + + return [ + 'stdout' => (string)$stdout, + 'stderr' => (string)$stderr, + 'exit_code' => $exitCode, + ]; + } + + private static function minioClientCommandTimeoutSeconds(): int + { + $configured = getenv('MINIO_MC_COMMAND_TIMEOUT_SECONDS'); + if (is_numeric($configured) && (int)$configured > 0) { + return (int)$configured; + } + + return self::MINIO_MC_COMMAND_TIMEOUT_SECONDS; + } + + private static function minioClientCommandLabel(array $arguments): string + { + $parts = array_values(array_map('strval', $arguments)); + if (($parts[0] ?? '') === 'alias' && ($parts[1] ?? '') === 'set') { + if (isset($parts[4])) { + $parts[4] = '[redacted]'; + } + if (isset($parts[5])) { + $parts[5] = '[redacted]'; + } + } + + return 'mc ' . implode(' ', array_slice($parts, 0, 8)); + } + + private static function minioClientBinary(): ?string + { + $configured = trim((string)(getenv('MINIO_MC_BINARY') ?: '')); + if ($configured !== '') { + return $configured; + } + + foreach (['/usr/local/bin/mc', '/usr/bin/mc'] as $candidate) { + if (is_file($candidate) && is_executable($candidate)) { + return $candidate; + } + } + + return self::executableFromPath('mc') ?? self::cachedMinioClientBinary(); + } + + private static function cachedMinioClientBinary(): ?string + { + if (!self::minioClientAutoInstallEnabled()) { + return null; + } + + $cacheDir = trim((string)(getenv('MINIO_MC_CACHE_DIR') ?: '')); + if ($cacheDir === '') { + $cacheDir = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'truckwash-minio-client'; + } + $binary = rtrim($cacheDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'mc'; + if (is_file($binary) && is_executable($binary)) { + return $binary; + } + + if (!is_dir($cacheDir) && !mkdir($cacheDir, 0700, true) && !is_dir($cacheDir)) { + throw new RuntimeException('Could not create MinIO Client cache directory.'); + } + + $lock = @fopen($cacheDir . DIRECTORY_SEPARATOR . 'mc.lock', 'c'); + if (is_resource($lock)) { + @flock($lock, LOCK_EX); + } + + try { + if (is_file($binary) && is_executable($binary)) { + return $binary; + } + + self::downloadMinioClientBinary($binary); + self::assertMinioClientUsable($binary); + } finally { + if (is_resource($lock)) { + @flock($lock, LOCK_UN); + fclose($lock); + } + } + + return $binary; + } + + private static function minioClientAutoInstallEnabled(): bool + { + $configured = getenv('MINIO_MC_AUTO_INSTALL'); + $value = strtolower(trim((string)($configured === false ? '1' : $configured))); + return !in_array($value, ['0', 'false', 'no', 'off'], true); + } + + private static function downloadMinioClientBinary(string $binary): void + { + $url = self::minioClientDownloadUrl(); + $temp = $binary . '.download-' . getmypid(); + $output = @fopen($temp, 'wb'); + if (!is_resource($output)) { + throw new RuntimeException('Could not write MinIO Client download cache.'); + } + + $ok = false; + $error = ''; + try { + if (function_exists('curl_init')) { + $curl = curl_init($url); + if ($curl === false) { + throw new RuntimeException('Could not initialize MinIO Client download.'); + } + curl_setopt_array($curl, [ + CURLOPT_FILE => $output, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => min(2, self::MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS), + CURLOPT_TIMEOUT => self::minioClientDownloadTimeoutSeconds(), + CURLOPT_FAILONERROR => true, + CURLOPT_USERAGENT => 'truckwash-replication-manager/1.0', + ]); + $ok = curl_exec($curl) === true; + $error = curl_error($curl); + $status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + curl_close($curl); + if (!$ok && $status > 0) { + $error = 'HTTP ' . $status; + } + } else { + $context = stream_context_create([ + 'http' => ['timeout' => self::minioClientDownloadTimeoutSeconds()], + 'https' => ['timeout' => self::minioClientDownloadTimeoutSeconds()], + ]); + $input = @fopen($url, 'rb', false, $context); + if (is_resource($input)) { + $ok = stream_copy_to_stream($input, $output) !== false; + fclose($input); + } else { + $error = 'download stream could not be opened'; + } + } + } finally { + fclose($output); + } + + if (!$ok || !is_file($temp) || (int)filesize($temp) <= 0) { + @unlink($temp); + throw new RuntimeException('Could not download MinIO Client (mc): ' . ($error !== '' ? $error : 'empty response')); + } + + @chmod($temp, 0755); + if (!@rename($temp, $binary)) { + @unlink($temp); + throw new RuntimeException('Could not install downloaded MinIO Client (mc).'); + } + @chmod($binary, 0755); + } + + private static function minioClientDownloadTimeoutSeconds(): int + { + $configured = getenv('MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS'); + if (is_numeric($configured) && (int)$configured > 0) { + return (int)$configured; + } + + return self::MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS; + } + + private static function minioClientDownloadUrl(): string + { + $configured = trim((string)(getenv('MINIO_MC_DOWNLOAD_URL') ?: '')); + if ($configured !== '') { + return $configured; + } + + $platform = self::minioClientDownloadPlatform(); + if ($platform === null) { + throw new RuntimeException('Automatic MinIO Client download is not supported on this PHP runtime platform.'); + } + + return self::MINIO_MC_DOWNLOAD_BASE_URL . '/' . $platform . '/mc'; + } + + private static function minioClientDownloadPlatform(): ?string + { + if (PHP_OS_FAMILY !== 'Linux') { + return null; + } + + $machine = strtolower((string)php_uname('m')); + return match ($machine) { + 'x86_64', 'amd64' => 'linux-amd64', + 'aarch64', 'arm64' => 'linux-arm64', + default => null, + }; + } + + private static function assertMinioClientUsable(string $binary): void + { + $result = self::runProcessWithTimeout([$binary, '--version'], self::MINIO_MC_COMMAND_TIMEOUT_SECONDS); + if (($result['exit_code'] ?? 1) !== 0) { + @unlink($binary); + $message = trim((string)($result['stderr'] ?? '')) ?: trim((string)($result['stdout'] ?? '')) ?: 'mc --version failed'; + throw new RuntimeException('Downloaded MinIO Client (mc) failed verification: ' . $message); + } + } + + private static function runProcessWithTimeout(array $command, int $timeoutSeconds): array + { + $pipes = []; + $process = @proc_open(array_map('strval', $command), [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes); + if (!is_resource($process)) { + return [ + 'stdout' => '', + 'stderr' => 'Process could not be started.', + 'exit_code' => 127, + 'timed_out' => false, + ]; + } + + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $stdout = ''; + $stderr = ''; + $exitCode = null; + $timedOut = false; + $deadline = microtime(true) + max(1, $timeoutSeconds); + + while (true) { + $stdout .= (string)stream_get_contents($pipes[1]); + $stderr .= (string)stream_get_contents($pipes[2]); + + $status = proc_get_status($process); + if (empty($status['running'])) { + $exitCode = (int)($status['exitcode'] ?? -1); + break; + } + + if (microtime(true) >= $deadline) { + $timedOut = true; + proc_terminate($process); + usleep(100000); + $status = proc_get_status($process); + if (!empty($status['running'])) { + proc_terminate($process, 9); + } + break; + } + + usleep(50000); + } + + $stdout .= (string)stream_get_contents($pipes[1]); + fclose($pipes[1]); + $stderr .= (string)stream_get_contents($pipes[2]); + fclose($pipes[2]); + + if (!$timedOut) { + $closeCode = proc_close($process); + if ($exitCode === null || $exitCode < 0) { + $exitCode = $closeCode; + } + } + + return [ + 'stdout' => $stdout, + 'stderr' => $stderr, + 'exit_code' => $timedOut ? 124 : (int)$exitCode, + 'timed_out' => $timedOut, + ]; + } + + private static function executableFromPath(string $name): ?string + { + $path = (string)(getenv('PATH') ?: ''); + if ($path === '') { + return null; + } + + foreach (explode(PATH_SEPARATOR, $path) as $dir) { + $dir = rtrim((string)$dir, DIRECTORY_SEPARATOR); + if ($dir === '') { + continue; + } + $candidate = $dir . DIRECTORY_SEPARATOR . $name; + if (is_file($candidate) && is_executable($candidate)) { + return $candidate; + } + } + + return null; + } + + private function createMinioConfigDir(): string + { + $dir = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'truckwash-mc-' . bin2hex(random_bytes(6)); + if (!mkdir($dir, 0700, true) && !is_dir($dir)) { + throw new RuntimeException('Could not create MinIO Client config directory.'); + } + return $dir; + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $entries = scandir($dir); + if (!is_array($entries)) { + @rmdir($dir); + return; + } + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . DIRECTORY_SEPARATOR . $entry; + if (is_dir($path)) { + $this->removeDirectory($path); + } else { + @unlink($path); + } + } + @rmdir($dir); + } + + private static function decodeMinioJsonOutput(string $output): mixed + { + $trimmed = trim($output); + if ($trimmed === '') { + return null; + } + + $decoded = json_decode($trimmed, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + + $items = []; + foreach (preg_split('/\R/', $trimmed) ?: [] as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + $decodedLine = json_decode($line, true); + if (json_last_error() === JSON_ERROR_NONE) { + $items[] = $decodedLine; + } + } + + return $items !== [] ? $items : null; + } + + public static function minioAvailableBytesFromAdminInfo(mixed $value): ?int + { + $values = []; + self::collectMinioAvailableByteValues($value, $values); + if ($values === []) { + return null; + } + + return max($values); + } + + private static function collectMinioAvailableByteValues(mixed $value, array &$values): void + { + if (!is_array($value)) { + return; + } + + foreach ($value as $key => $entry) { + $normalizedKey = strtolower((string)$key); + if (in_array($normalizedKey, ['available', 'availablebytes', 'available_bytes', 'avail', 'availspace', 'avail_space', 'availablespace', 'available_space', 'free', 'freebytes', 'free_bytes', 'freespace', 'free_space'], true)) { + $bytes = self::parseMinioByteValue($entry); + if ($bytes !== null && $bytes >= 0) { + $values[] = $bytes; + } + } + if (is_array($entry)) { + self::collectMinioAvailableByteValues($entry, $values); + } + } + } + + private static function parseMinioByteValue(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + if (is_float($value)) { + return (int)$value; + } + + $text = trim((string)$value); + if ($text === '') { + return null; + } + if (ctype_digit($text)) { + return (int)$text; + } + if (preg_match('/^([0-9]+(?:\.[0-9]+)?)\s*([kmgtp]?i?b?|bytes?)$/i', $text, $matches) !== 1) { + return null; + } + + $number = (float)$matches[1]; + $unit = strtolower($matches[2]); + $multipliers = [ + 'b' => 1, + 'byte' => 1, + 'bytes' => 1, + 'kb' => 1000, + 'kib' => 1024, + 'mb' => 1000 ** 2, + 'mib' => 1024 ** 2, + 'gb' => 1000 ** 3, + 'gib' => 1024 ** 3, + 'tb' => 1000 ** 4, + 'tib' => 1024 ** 4, + 'pb' => 1000 ** 5, + 'pib' => 1024 ** 5, + ]; + + return (int)floor($number * ($multipliers[$unit] ?? 1)); + } + + private function redisClient(array $host): PredisClient + { + $credentials = $this->credentials($host); + $params = [ + 'scheme' => 'tcp', + 'host' => (string)$host['host'], + 'port' => (int)$host['port'], + 'database' => (int)($host['database_index'] ?? 0), + 'password' => $credentials['password'], + ]; + + if (($credentials['username'] ?? '') !== '' && $credentials['username'] !== 'default') { + $params['username'] = $credentials['username']; + } + + return new PredisClient($params); + } + + private function redisInfo(PredisClient $client): array + { + $info = $client->info('replication'); + if (is_array($info)) { + return isset($info['Replication']) && is_array($info['Replication']) + ? $info['Replication'] + : $info; + } + + $parsed = []; + foreach (explode("\n", (string)$info) as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#') || !str_contains($line, ':')) { + continue; + } + [$key, $value] = explode(':', $line, 2); + $parsed[$key] = trim($value); + } + return $parsed; + } + + private function mysqliExec(mysqli $connection, string $sql): void + { + $connection->query($sql); + } + + private function mysqliSelectOne(mysqli $connection, string $sql): array + { + $result = $connection->query($sql); + if ($result === false) { + return []; + } + $row = $result->fetch_assoc(); + return is_array($row) ? $row : []; + } + + private function refreshStatuses(): void + { + foreach ($this->listHosts() as $host) { + $activeOperation = $this->activeOperation((string)$host['kind'], (int)$host['id']); + if ($activeOperation !== null) { + if ($this->shouldAdvanceActiveProvisionDuringRefresh($host, $activeOperation)) { + try { + $this->provisionHost((string)$host['kind'], (int)$host['id']); + } catch (Throwable $throwable) { + $this->storeStatus($host, [ + 'status' => 'degraded', + 'replication_percent' => self::lastStatusReplicationPercent($host, 0.0), + 'lag_seconds' => null, + 'blockers' => [$throwable->getMessage()], + 'raw' => ['active_operation_refresh_failed' => true], + 'checked_at' => date('c'), + ]); + } + } + continue; + } + + try { + $status = match ((string)$host['kind']) { + self::KIND_DATABASE => $this->testDatabaseHost($host), + self::KIND_REDIS => $this->testRedisHost($host), + self::KIND_MINIO => $this->testMinioHost($host), + default => throw new RuntimeException('Unsupported replication kind.'), + }; + $this->storeStatus($host, $status); + } catch (Throwable $throwable) { + $this->storeStatus($host, [ + 'status' => 'degraded', + 'replication_percent' => 0, + 'lag_seconds' => null, + 'blockers' => [$throwable->getMessage()], + 'raw' => [], + 'checked_at' => date('c'), + ]); + } + } + + $this->writeBootstrapSnapshot(); + } + + private function shouldAdvanceActiveProvisionDuringRefresh(array $host, array $activeOperation): bool + { + return (string)($host['kind'] ?? '') === self::KIND_MINIO + && (string)($host['role'] ?? '') !== 'primary' + && (string)($activeOperation['operation'] ?? '') === 'provision'; + } + + private function storeStatus(array $host, array $status): void + { + $publicStatus = [ + 'status' => (string)($status['status'] ?? 'unknown'), + 'replication_percent' => round((float)($status['replication_percent'] ?? 0), 2), + 'lag_seconds' => $status['lag_seconds'] ?? null, + 'blockers' => array_values(array_filter($status['blockers'] ?? [])), + 'raw' => $status['raw'] ?? [], + 'checked_at' => (string)($status['checked_at'] ?? date('c')), + ]; + + $this->execute( + "UPDATE replication_hosts SET status = ?, last_status_json = ?, last_checked_at = NOW() WHERE id = ?", + 'ssi', + [$publicStatus['status'], self::jsonEncode($publicStatus), (int)$host['id']] + ); + $this->execute( + "INSERT INTO replication_status_snapshots + (host_id, kind, status, replication_percent, lag_seconds, blockers_json, raw_status_json) + VALUES (?, ?, ?, ?, ?, ?, ?)", + 'issdiss', + [ + (int)$host['id'], + (string)$host['kind'], + $publicStatus['status'], + (float)$publicStatus['replication_percent'], + $publicStatus['lag_seconds'], + self::jsonEncode($publicStatus['blockers']), + self::jsonEncode($publicStatus['raw']), + ] + ); + + $this->completeReadyMinioProvisionOperation($host, $publicStatus); + + if (class_exists(coolify_manager::class)) { + coolify_manager::syncDeploymentStateForReplicationHost((int)$host['id']); + } + } + + private function completeReadyMinioProvisionOperation(array $host, array $status): void + { + if ((string)($host['kind'] ?? '') !== self::KIND_MINIO || (string)($host['role'] ?? '') === 'primary') { + return; + } + + $blockers = array_values(array_filter($status['blockers'] ?? [])); + if ((string)($status['status'] ?? '') !== 'ok' + || round((float)($status['replication_percent'] ?? 0), 2) < 100.0 + || $blockers !== []) { + return; + } + + $operationId = $this->activeOperationId(self::KIND_MINIO, (int)$host['id'], 'provision'); + if ($operationId !== null) { + $this->finishOperation($operationId, 'completed', 100.0, 'MinIO replication target is caught up.', []); + } + } + + private function buildReplicationSummary(string $kind, array $hosts): array + { + $replicas = []; + $blockers = []; + $percents = []; + $statuses = []; + + foreach ($hosts as $host) { + if (($host['role'] ?? '') === 'primary') { + continue; + } + + $public = $this->publicHost($host); + $replicas[] = $public; + $status = is_array($public['last_status'] ?? null) ? $public['last_status'] : []; + $activeProgress = $public['active_operation']['progress_percent'] ?? null; + $percent = is_numeric($activeProgress) && (float)$activeProgress > 0 + ? (float)$activeProgress + : (float)($status['replication_percent'] ?? $public['replication_percent'] ?? 0); + $percents[] = $percent; + $statuses[] = (string)($status['status'] ?? $public['status'] ?? 'unknown'); + foreach ($status['blockers'] ?? [] as $blocker) { + $blockers[] = (string)$blocker; + } + } + + if ($replicas === []) { + return [ + 'status' => 'not_configured', + 'min_percent' => 0.0, + 'average_percent' => 0.0, + 'replicas' => [], + 'blockers' => ['No ' . $kind . ' replicas configured.'], + ]; + } + + $min = min($percents); + $average = array_sum($percents) / max(1, count($percents)); + $status = ($min >= 100.0 && $blockers === []) ? 'ok' : 'degraded'; + if (in_array('down', $statuses, true)) { + $status = 'down'; + } + + return [ + 'status' => $status, + 'min_percent' => round($min, 2), + 'average_percent' => round($average, 2), + 'replicas' => $replicas, + 'blockers' => array_values(array_unique($blockers)), + ]; + } + + private function publicHost(?array $host): ?array + { + if ($host === null) { + return null; + } + + $lastStatus = self::jsonDecode($host['last_status_json'] ?? null); + $credentials = $this->credentials($host); + $options = $this->decodeOptions($host); + $hasCoolifyDeployment = isset($options['coolify_target_id']) + || isset($options['coolify_instance_id']) + || (string)($options['deployment_provider'] ?? '') === 'coolify'; + $coolifyDeployment = $hasCoolifyDeployment && isset($host['id']) + ? coolify_manager::deploymentMetadataForReplicationHost((int)$host['id']) + : null; + $activeOperation = isset($host['id']) + ? $this->activeOperation((string)$host['kind'], (int)$host['id']) + : null; + $replicationPercent = round((float)($lastStatus['replication_percent'] ?? (($host['role'] ?? '') === 'primary' ? 100 : 0)), 2); + $status = self::publicReplicationStatus($host, $lastStatus, $activeOperation, $replicationPercent); + $database = match ((string)$host['kind']) { + self::KIND_DATABASE => (string)($host['database_name'] ?? ''), + self::KIND_REDIS => (int)($host['database_index'] ?? 0), + self::KIND_MINIO => null, + default => null, + }; + + return [ + 'id' => (int)$host['id'], + 'kind' => (string)$host['kind'], + 'label' => (string)$host['label'], + 'host' => (string)$host['host'], + 'port' => (int)$host['port'], + 'database' => $database, + 'endpoint' => (string)($options['endpoint'] ?? (((string)$host['kind'] === self::KIND_MINIO) ? self::minioEndpoint($host) : '')), + 'scheme' => $options['scheme'] ?? null, + 'buckets' => ((string)$host['kind'] === self::KIND_MINIO) ? self::normalizeMinioBuckets($options['buckets'] ?? []) : [], + 'console_port' => ((string)$host['kind'] === self::KIND_MINIO) ? (int)($options['console_port'] ?? 9001) : null, + 'replication_transfer_limit' => ((string)$host['kind'] === self::KIND_MINIO) ? self::minioReplicationTransferLimitFromOptions($options) : null, + 'space_headroom_percent' => ((string)$host['kind'] === self::KIND_MINIO) ? (float)($options['space_headroom_percent'] ?? self::MINIO_SPACE_HEADROOM_PERCENT) : null, + 'role' => (string)$host['role'], + 'status' => $status, + 'replication_source_id' => isset($host['replication_source_id']) ? (int)$host['replication_source_id'] : null, + 'ssl_mode' => $host['ssl_mode'] ?? null, + 'replication_percent' => $replicationPercent, + 'last_status' => array_replace($lastStatus, ['status' => $status]), + 'active_operation' => $activeOperation, + 'deployment_provider' => (string)($options['deployment_provider'] ?? ($coolifyDeployment !== null ? 'coolify' : 'manual')), + 'coolify' => $coolifyDeployment, + 'availability_state' => $coolifyDeployment['availability_state'] ?? null, + 'last_checked_at' => $host['last_checked_at'] ?? null, + 'credential_summary' => [ + 'username' => $credentials['username'] !== '' ? replication_secret_box::mask($credentials['username']) : '', + 'password_set' => $credentials['password'] !== '', + 'admin_username' => $credentials['admin_username'] !== '' ? replication_secret_box::mask($credentials['admin_username']) : '', + 'admin_password_set' => $credentials['admin_password'] !== '', + 'replication_username' => $credentials['replication_username'] !== '' ? replication_secret_box::mask($credentials['replication_username']) : '', + 'replication_password_set' => $credentials['replication_password'] !== '', + ], + 'created_at' => $host['created_at'] ?? null, + 'updated_at' => $host['updated_at'] ?? null, + 'deleted_at' => $host['deleted_at'] ?? null, + ]; + } + + private static function publicReplicationStatus(array $host, array $lastStatus, ?array $activeOperation, float $replicationPercent): string + { + $hostStatus = (string)($host['status'] ?? 'unknown'); + $status = (string)($lastStatus['status'] ?? $hostStatus); + + if (in_array($hostStatus, ['removed', 'inactive', 'not_configured', 'down'], true)) { + return $hostStatus; + } + if (in_array($status, ['removed', 'inactive', 'not_configured', 'down'], true)) { + return $status; + } + if ($activeOperation !== null || $hostStatus === 'provisioning' || $status === 'provisioning') { + return 'provisioning'; + } + if ($status === 'ok') { + return self::replicationHealthStatus( + true, + (string)($host['role'] ?? ''), + $replicationPercent, + array_values(array_filter($lastStatus['blockers'] ?? [])) + ); + } + + return $status !== '' ? $status : 'unknown'; + } + + private static function sanitizePublicLastStatus(array $host, array $lastStatus): array + { + if ((string)($host['kind'] ?? '') !== self::KIND_MINIO || (string)($host['role'] ?? '') !== 'primary') { + return $lastStatus; + } + + $blockers = array_values(array_filter(array_map( + static fn(mixed $blocker): string => trim((string)$blocker), + $lastStatus['blockers'] ?? [] + ))); + if ($blockers === []) { + return $lastStatus; + } + + $onlyObjectScanTimeouts = true; + foreach ($blockers as $blocker) { + $normalized = strtolower($blocker); + if (!str_contains($normalized, 'could not be inspected') + || !str_contains($normalized, 'listobjectsv2') + || !str_contains($normalized, 'timed out')) { + $onlyObjectScanTimeouts = false; + break; + } + } + + if (!$onlyObjectScanTimeouts || round((float)($lastStatus['replication_percent'] ?? 0), 2) < 100.0) { + return $lastStatus; + } + + $lastStatus['status'] = 'ok'; + $lastStatus['blockers'] = []; + $lastStatus['raw']['suppressed_blockers'] = $blockers; + $lastStatus['raw']['suppressed_reason'] = 'MinIO primary object-scan timeouts do not indicate primary availability failure.'; + return $lastStatus; + } + + private static function normalizeMinioAddress(string $host, mixed $port, mixed $scheme): array + { + $raw = trim($host); + $hasScheme = preg_match('/^https?:\/\//i', $raw) === 1; + $parsed = parse_url($hasScheme ? $raw : 'http://' . $raw); + if (!is_array($parsed) || empty($parsed['host'])) { + throw new RuntimeException('MinIO endpoint host is invalid.'); + } + + $normalizedScheme = strtolower(trim((string)($scheme ?: ($parsed['scheme'] ?? 'http')))); + if (!in_array($normalizedScheme, ['http', 'https'], true)) { + throw new RuntimeException('MinIO scheme must be http or https.'); + } + + $normalizedHost = trim((string)$parsed['host']); + $portValue = ($port !== null && trim((string)$port) !== '') + ? $port + : ($parsed['port'] ?? ($normalizedScheme === 'https' ? 443 : 9000)); + $normalizedPort = (int)$portValue; + + return [$normalizedHost, $normalizedPort, $normalizedScheme]; + } + + private static function minioEndpointFromParts(string $scheme, string $host, int $port): string + { + return strtolower($scheme) . '://' . $host . ':' . $port; + } + + private static function minioEndpoint(array $host): string + { + $options = isset($host['options']) && is_array($host['options']) + ? $host['options'] + : self::jsonDecode($host['options_json'] ?? null); + $endpoint = trim((string)($options['endpoint'] ?? '')); + if ($endpoint !== '') { + return $endpoint; + } + + return self::minioEndpointFromParts((string)($options['scheme'] ?? 'http'), (string)$host['host'], (int)$host['port']); + } + + private function normalizeHostInput(string $kind, array $input): array + { + $host = trim((string)($input['host'] ?? $input['endpoint'] ?? '')); + if ($host === '') { + throw new RuntimeException('Host is required.'); + } + + $scheme = null; + $defaultPort = match ($kind) { + self::KIND_DATABASE => 3306, + self::KIND_REDIS => 6379, + self::KIND_MINIO => 9000, + }; + if ($kind === self::KIND_MINIO) { + [$host, $port, $scheme] = self::normalizeMinioAddress($host, $input['port'] ?? null, $input['scheme'] ?? null); + } else { + $port = (int)($input['port'] ?? $defaultPort); + } + if ($port <= 0 || $port > 65535) { + throw new RuntimeException('Port must be between 1 and 65535.'); + } + + $label = trim((string)($input['label'] ?? '')); + if ($label === '') { + $label = $host . ':' . $port; + } + + $username = trim((string)($input['username'] ?? $input['access_key'] ?? $input['user'] ?? '')); + $password = (string)($input['password'] ?? $input['secret_key'] ?? ''); + $databaseName = null; + $databaseIndex = null; + if ($kind === self::KIND_DATABASE) { + $databaseName = trim((string)($input['database'] ?? $input['database_name'] ?? '')); + if ($databaseName === '' || $username === '') { + throw new RuntimeException('Database name and username are required for database replication hosts.'); + } + } elseif ($kind === self::KIND_REDIS) { + $databaseIndex = (int)($input['database'] ?? $input['database_index'] ?? 0); + if ($databaseIndex < 0) { + throw new RuntimeException('Redis database index must be zero or greater.'); + } + } else { + if ($username === '' || $password === '') { + throw new RuntimeException('Access key and secret key are required for MinIO replication hosts.'); + } + } + + $options = is_array($input['options'] ?? null) ? $input['options'] : []; + if (isset($input['deployment_provider'])) { + $provider = strtolower(trim((string)$input['deployment_provider'])); + if (!in_array($provider, ['manual', 'coolify'], true)) { + throw new RuntimeException('Deployment provider must be manual or coolify.'); + } + $options['deployment_provider'] = $provider; + } + if (isset($input['coolify_target_id'])) { + $options['coolify_target_id'] = (int)$input['coolify_target_id']; + } + if (isset($input['coolify_instance_id'])) { + $options['coolify_instance_id'] = (int)$input['coolify_instance_id']; + } + if ($kind === self::KIND_MINIO) { + $headroom = (float)($input['space_headroom_percent'] ?? $options['space_headroom_percent'] ?? self::MINIO_SPACE_HEADROOM_PERCENT); + $transferLimit = array_key_exists('replication_transfer_limit', $input) + ? self::normalizeMinioTransferLimit($input['replication_transfer_limit'], false) + : self::minioReplicationTransferLimitFromOptions($options); + $options = array_replace($options, [ + 'scheme' => $scheme ?: 'http', + 'endpoint' => self::minioEndpointFromParts($scheme ?: 'http', $host, $port), + 'buckets' => self::normalizeMinioBuckets($input['buckets'] ?? $options['buckets'] ?? self::MINIO_DEFAULT_BUCKETS), + 'console_port' => (int)($input['console_port'] ?? $options['console_port'] ?? 9001), + 'replication_transfer_limit' => $transferLimit, + 'space_headroom_percent' => max(0.0, $headroom), + ]); + } + + return [ + 'label' => $label, + 'host' => $host, + 'port' => $port, + 'database_name' => $databaseName, + 'database_index' => $databaseIndex, + 'username' => $username, + 'password_secret' => replication_secret_box::encrypt($password), + 'admin_username' => trim((string)($input['admin_username'] ?? '')), + 'admin_password_secret' => replication_secret_box::encrypt((string)($input['admin_password'] ?? '')), + 'replication_username' => trim((string)($input['replication_username'] ?? '')), + 'replication_password_secret' => replication_secret_box::encrypt((string)($input['replication_password'] ?? '')), + 'ssl_mode' => strtoupper(trim((string)($input['ssl_mode'] ?? 'DISABLED'))) ?: 'DISABLED', + 'options' => $options, + ]; + } + + private function transientHost(string $kind, array $input): array + { + $normalized = $this->normalizeHostInput($kind, $input); + $role = strtolower(trim((string)($input['role'] ?? 'replica'))); + if (!in_array($role, ['primary', 'replica'], true)) { + $role = 'replica'; + } + + return array_merge($normalized, [ + 'id' => 0, + 'kind' => $kind, + 'role' => $role, + 'status' => 'unknown', + 'replication_source_id' => null, + 'last_status_json' => null, + 'last_checked_at' => null, + 'created_at' => null, + 'updated_at' => null, + 'deleted_at' => null, + 'test_connectivity_only' => true, + ]); + } + + private function ensureEnvironmentPrimaryRows(): void + { + if ($this->primaryHost(self::KIND_DATABASE) === null && isset($GLOBALS['CONFIG_DB']) && is_array($GLOBALS['CONFIG_DB'])) { + $config = $GLOBALS['CONFIG_DB']; + if (!empty($config['host']) && !empty($config['database']) && !empty($config['user'])) { + $this->insertEnvironmentPrimary(self::KIND_DATABASE, [ + 'label' => 'Current database primary', + 'host' => (string)$config['host'], + 'port' => (int)($config['port'] ?? 3306), + 'database_name' => (string)$config['database'], + 'database_index' => null, + 'username' => (string)$config['user'], + 'password_secret' => replication_secret_box::encrypt((string)($config['password'] ?? '')), + 'ssl_mode' => (string)($config['ssl_mode'] ?? 'DISABLED'), + ]); + } + } + + if ($this->primaryHost(self::KIND_REDIS) === null && isset($GLOBALS['REDIS_CONFIG']) && is_array($GLOBALS['REDIS_CONFIG'])) { + $config = $GLOBALS['REDIS_CONFIG']; + if (!empty($config['host'])) { + $this->insertEnvironmentPrimary(self::KIND_REDIS, [ + 'label' => 'Current Redis primary', + 'host' => (string)$config['host'], + 'port' => (int)($config['port'] ?? 6379), + 'database_name' => null, + 'database_index' => (int)($config['database'] ?? 0), + 'username' => (string)($config['user'] ?? ''), + 'password_secret' => replication_secret_box::encrypt((string)($config['password'] ?? '')), + 'ssl_mode' => null, + ]); + } + } + + if ($this->primaryHost(self::KIND_MINIO) === null && isset($GLOBALS['MINIO']) && is_array($GLOBALS['MINIO'])) { + $config = $GLOBALS['MINIO']; + $endpoint = trim((string)($config['endpoint'] ?? '')); + $accessKey = trim((string)($config['access_key'] ?? '')); + if ($endpoint !== '' && $accessKey !== '') { + [$host, $port, $scheme] = self::normalizeMinioAddress($endpoint, null, null); + $buckets = self::normalizeMinioBuckets($config['buckets'] ?? self::MINIO_DEFAULT_BUCKETS); + $this->insertEnvironmentPrimary(self::KIND_MINIO, [ + 'label' => 'Current MinIO primary', + 'host' => $host, + 'port' => $port, + 'database_name' => null, + 'database_index' => null, + 'username' => $accessKey, + 'password_secret' => replication_secret_box::encrypt((string)($config['secret_key'] ?? '')), + 'ssl_mode' => null, + 'options' => [ + 'source' => 'environment', + 'scheme' => $scheme, + 'endpoint' => self::minioEndpointFromParts($scheme, $host, $port), + 'buckets' => $buckets, + 'console_port' => (int)($config['console_port'] ?? 9001), + 'space_headroom_percent' => self::MINIO_SPACE_HEADROOM_PERCENT, + ], + ]); + } + } + } + + private function insertEnvironmentPrimary(string $kind, array $host): void + { + $this->execute( + "INSERT INTO replication_hosts ( + kind, label, host, port, database_name, database_index, username, password_secret, + role, status, ssl_mode, options_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'primary', 'unknown', ?, ?)", + 'sssissssss', + [ + $kind, + $host['label'], + $host['host'], + $host['port'], + $host['database_name'], + $host['database_index'], + $host['username'], + $host['password_secret'], + $host['ssl_mode'], + self::jsonEncode(array_replace(['source' => 'environment'], is_array($host['options'] ?? null) ? $host['options'] : [])), + ] + ); + } + + private function writeBootstrapSnapshot(): void + { + $databasePrimary = $this->primaryHost(self::KIND_DATABASE); + $redisPrimary = $this->primaryHost(self::KIND_REDIS); + $minioPrimary = $this->primaryHost(self::KIND_MINIO); + $active = []; + + if ($databasePrimary !== null) { + $credentials = $this->credentials($databasePrimary); + $active['database'] = [ + 'id' => (int)$databasePrimary['id'], + 'host' => (string)$databasePrimary['host'], + 'port' => (int)$databasePrimary['port'], + 'database' => (string)$databasePrimary['database_name'], + 'user' => $credentials['username'], + 'password_secret' => $databasePrimary['password_secret'] ?? '', + 'ssl_mode' => (string)($databasePrimary['ssl_mode'] ?? 'DISABLED'), + ]; + } + + if ($redisPrimary !== null) { + $credentials = $this->credentials($redisPrimary); + $active['redis'] = [ + 'id' => (int)$redisPrimary['id'], + 'host' => (string)$redisPrimary['host'], + 'port' => (int)$redisPrimary['port'], + 'database' => (int)($redisPrimary['database_index'] ?? 0), + 'user' => $credentials['username'], + 'password_secret' => $redisPrimary['password_secret'] ?? '', + ]; + } + + if ($minioPrimary !== null) { + $credentials = $this->credentials($minioPrimary); + $options = $this->decodeOptions($minioPrimary); + $active['minio'] = [ + 'id' => (int)$minioPrimary['id'], + 'endpoint' => self::minioEndpoint($minioPrimary), + 'access_key' => $credentials['username'], + 'secret_key_secret' => $minioPrimary['password_secret'] ?? '', + 'buckets' => self::normalizeMinioBuckets($options['buckets'] ?? self::MINIO_DEFAULT_BUCKETS), + ]; + } + + replication_bootstrap_config::writeSnapshot([ + 'version' => 1, + 'generated_at' => date('c'), + 'active' => $active, + 'failover' => [ + 'config' => $this->failoverConfigForSnapshot(), + 'hosts' => $this->failoverHostsForSnapshot(), + ], + ]); + } + + private function failoverConfigForSnapshot(): array + { + $config = replica_failover_manager::configDefaults(); + + try { + foreach ($this->selectRows("SELECT variable, value FROM module_config WHERE module = 'Failover'") as $row) { + $variable = (string)($row['variable'] ?? ''); + if (!array_key_exists($variable, $config)) { + continue; + } + $config[$variable] = $row['value'] ?? ''; + } + } catch (Throwable) { + } + + return replica_failover_manager::normalizeConfig($config); + } + + private function failoverHostsForSnapshot(): array + { + return [ + self::KIND_DATABASE => array_map( + fn(array $host): array => $this->bootstrapSnapshotHost($host), + $this->listHosts(self::KIND_DATABASE) + ), + self::KIND_REDIS => array_map( + fn(array $host): array => $this->bootstrapSnapshotHost($host), + $this->listHosts(self::KIND_REDIS) + ), + self::KIND_MINIO => array_map( + fn(array $host): array => $this->bootstrapSnapshotHost($host), + $this->listHosts(self::KIND_MINIO) + ), + ]; + } + + private function bootstrapSnapshotHost(array $host): array + { + return [ + 'id' => (int)$host['id'], + 'kind' => (string)$host['kind'], + 'label' => (string)$host['label'], + 'host' => (string)$host['host'], + 'port' => (int)$host['port'], + 'database_name' => $host['database_name'] ?? null, + 'database_index' => isset($host['database_index']) ? (int)$host['database_index'] : null, + 'username' => (string)($host['username'] ?? ''), + 'password_secret' => (string)($host['password_secret'] ?? ''), + 'admin_username' => (string)($host['admin_username'] ?? ''), + 'admin_password_secret' => (string)($host['admin_password_secret'] ?? ''), + 'replication_username' => (string)($host['replication_username'] ?? ''), + 'replication_password_secret' => (string)($host['replication_password_secret'] ?? ''), + 'role' => (string)$host['role'], + 'status' => (string)$host['status'], + 'replication_source_id' => isset($host['replication_source_id']) ? (int)$host['replication_source_id'] : null, + 'ssl_mode' => $host['ssl_mode'] ?? null, + 'options_json' => $host['options_json'] ?? null, + 'last_status_json' => $host['last_status_json'] ?? null, + 'last_checked_at' => $host['last_checked_at'] ?? null, + 'updated_at' => $host['updated_at'] ?? null, + 'deleted_at' => $host['deleted_at'] ?? null, + ]; + } + + private function switchPrimary(string $kind, int $newPrimaryId, int $oldPrimaryId): void + { + $this->execute( + "UPDATE replication_hosts SET role = 'inactive', status = 'inactive' WHERE kind = ? AND role = 'primary' AND id <> ?", + 'si', + [$kind, $newPrimaryId] + ); + $this->execute( + "UPDATE replication_hosts SET role = 'primary', status = 'ok', replication_source_id = NULL WHERE kind = ? AND id = ?", + 'si', + [$kind, $newPrimaryId] + ); + $this->execute( + "UPDATE replication_hosts SET replication_source_id = ? WHERE kind = ? AND role = 'replica'", + 'is', + [$newPrimaryId, $kind] + ); + } + + private function credentials(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'] ?? ''), + 'replication_username' => (string)($host['replication_username'] ?? ''), + 'replication_password' => replication_secret_box::decrypt($host['replication_password_secret'] ?? ''), + ]; + } + + private function decodeOptions(array $host): array + { + if (isset($host['options']) && is_array($host['options'])) { + return $host['options']; + } + + return self::jsonDecode($host['options_json'] ?? null); + } + + private function listHosts(?string $kind = null, bool $includeDeleted = false): array + { + $where = []; + $types = ''; + $params = []; + if ($kind !== null) { + $where[] = 'kind = ?'; + $types .= 's'; + $params[] = $kind; + } + if (!$includeDeleted) { + $where[] = 'deleted_at IS NULL'; + } + + $sql = 'SELECT * FROM replication_hosts'; + if ($where !== []) { + $sql .= ' WHERE ' . implode(' AND ', $where); + } + $sql .= " ORDER BY FIELD(role, 'primary', 'replica', 'inactive'), id"; + + return $this->selectRows($sql, $types, $params); + } + + private function primaryHost(string $kind): ?array + { + return $this->selectOne( + "SELECT * FROM replication_hosts WHERE kind = ? AND role = 'primary' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1", + 's', + [$kind] + ); + } + + private function getHost(string $kind, int $id, bool $includeDeleted = false): array + { + $sql = 'SELECT * FROM replication_hosts WHERE kind = ? AND id = ?'; + if (!$includeDeleted) { + $sql .= ' AND deleted_at IS NULL'; + } + $host = $this->selectOne($sql . ' LIMIT 1', 'si', [$kind, $id]); + if ($host === null) { + throw new RuntimeException('Replication host was not found.'); + } + return $host; + } + + private function startOperation(string $kind, int $hostId, string $operation, ?int $actorUserId): int + { + $this->execute( + "INSERT INTO replication_operations (kind, host_id, operation, status, actor_user_id) + VALUES (?, ?, ?, 'running', ?)", + 'sisi', + [$kind, $hostId, $operation, $actorUserId] + ); + return $this->insertId(); + } + + private function activeOperationId(string $kind, int $hostId, string $operation): ?int + { + $operationRow = $this->selectOne( + "SELECT id FROM replication_operations + WHERE kind = ? AND host_id = ? AND operation = ? AND status = 'running' + ORDER BY id DESC + LIMIT 1", + 'sis', + [$kind, $hostId, $operation] + ); + + return $operationRow !== null ? (int)$operationRow['id'] : null; + } + + private function activeOperation(string $kind, int $hostId): ?array + { + $operation = $this->selectOne( + "SELECT id, operation, status, progress_percent, message, error_message, started_at, updated_at + FROM replication_operations + WHERE kind = ? AND host_id = ? AND status = 'running' + ORDER BY id DESC + LIMIT 1", + 'si', + [$kind, $hostId] + ); + + if ($operation === null) { + return null; + } + + return [ + 'id' => (int)$operation['id'], + 'operation' => (string)$operation['operation'], + 'status' => (string)$operation['status'], + 'progress_percent' => round((float)$operation['progress_percent'], 2), + 'message' => $operation['message'] ?? null, + 'error_message' => $operation['error_message'] ?? null, + 'started_at' => $operation['started_at'] ?? null, + 'updated_at' => $operation['updated_at'] ?? null, + ]; + } + + private function operationContext(int $operationId): array + { + $operation = $this->selectOne( + 'SELECT context_json FROM replication_operations WHERE id = ? LIMIT 1', + 'i', + [$operationId] + ); + + return self::jsonDecode($operation['context_json'] ?? null); + } + + private function updateOperationProgress(int $operationId, float $progress, string $message, array $context): void + { + $this->execute( + "UPDATE replication_operations + SET progress_percent = ?, message = ?, context_json = ? + WHERE id = ?", + 'dssi', + [max(0, min(100, $progress)), $message, self::jsonEncode($context), $operationId] + ); + } + + private function finishOperation(int $operationId, string $status, float $progress, ?string $message, array $errors): void + { + $this->execute( + "UPDATE replication_operations + SET status = ?, progress_percent = ?, message = ?, error_message = ?, completed_at = NOW() + WHERE id = ?", + 'sdssi', + [$status, $progress, $message, implode("\n", $errors), $operationId] + ); + } + + private function audit(string $kind, ?int $hostId, string $action, ?int $actorUserId, string $severity, array $context): void + { + $this->execute( + "INSERT INTO replication_audit_logs (kind, host_id, action, actor_user_id, severity, context_json) + VALUES (?, ?, ?, ?, ?, ?)", + 'sisiss', + [$kind, $hostId, $action, $actorUserId, $severity, self::jsonEncode($context)] + ); + } + + private function acquirePromotionLock() + { + $path = (defined('WD') ? WD : dirname(__DIR__)) . DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR . 'replication-promotion.lock'; + $dir = dirname($path); + if (!is_dir($dir) && !mkdir($dir, 0770, true) && !is_dir($dir)) { + throw new RuntimeException('Could not create promotion lock directory.'); + } + $handle = fopen($path, 'c'); + if ($handle === false || !flock($handle, LOCK_EX | LOCK_NB)) { + throw new RuntimeException('Another replication promotion is already running.'); + } + return $handle; + } + + private function releasePromotionLock($handle): void + { + if (is_resource($handle)) { + flock($handle, LOCK_UN); + fclose($handle); + } + } + + 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 replication 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 replication statement.'); + } + $stmt->bind_param($types, ...$params); + $stmt->execute(); + } + + private function insertId(): int + { + global $db; + return (int)$db->insert_id(); + } + + private static function jsonEncode(mixed $value): string + { + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Could not encode replication JSON payload.'); + } + return $json; + } + + private static function jsonDecode(mixed $value): array + { + if (!is_string($value) || trim($value) === '') { + return []; + } + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : []; + } +} diff --git a/services/nginx/app/classes/replication_schema_bootstrap.php b/services/nginx/app/classes/replication_schema_bootstrap.php new file mode 100644 index 00000000..4f596d06 --- /dev/null +++ b/services/nginx/app/classes/replication_schema_bootstrap.php @@ -0,0 +1,122 @@ +query($sql); + } + + self::ensureColumn('replication_operations', 'progress_percent', "DECIMAL(5,2) NOT NULL DEFAULT 0.00"); + self::ensureColumn('replication_operations', 'message', 'VARCHAR(512) NULL'); + self::ensureColumn('replication_operations', 'context_json', 'LONGTEXT NULL'); + + self::$initialized = true; + } + + 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"); + } +} diff --git a/services/nginx/app/classes/replication_secret_box.php b/services/nginx/app/classes/replication_secret_box.php new file mode 100644 index 00000000..87386a63 --- /dev/null +++ b/services/nginx/app/classes/replication_secret_box.php @@ -0,0 +1,102 @@ + base64_encode($nonce), + 'tag' => base64_encode($tag), + 'ciphertext' => base64_encode($ciphertext), + ], JSON_UNESCAPED_SLASHES)); + } + + public static function decrypt(?string $secret): string + { + $secret = (string)$secret; + if ($secret === '') { + return ''; + } + + if (!str_starts_with($secret, self::PREFIX)) { + return $secret; + } + + $payload = json_decode(base64_decode(substr($secret, strlen(self::PREFIX)), true) ?: '', true); + if (!is_array($payload)) { + throw new RuntimeException('Encrypted secret payload is invalid.'); + } + + $nonce = base64_decode((string)($payload['nonce'] ?? ''), true); + $tag = base64_decode((string)($payload['tag'] ?? ''), true); + $ciphertext = base64_decode((string)($payload['ciphertext'] ?? ''), true); + + if ($nonce === false || $tag === false || $ciphertext === false) { + throw new RuntimeException('Encrypted secret payload is incomplete.'); + } + + $plaintext = openssl_decrypt( + $ciphertext, + self::CIPHER, + self::key(), + OPENSSL_RAW_DATA, + $nonce, + $tag + ); + + if ($plaintext === false) { + throw new RuntimeException('Secret decryption failed.'); + } + + return $plaintext; + } + + public static function mask(?string $value): string + { + $value = (string)$value; + if ($value === '') { + return ''; + } + + $length = strlen($value); + if ($length <= 4) { + return str_repeat('*', $length); + } + + return substr($value, 0, 2) . str_repeat('*', max(4, $length - 4)) . substr($value, -2); + } + + private static function key(): string + { + $keyMaterial = (string)($GLOBALS['ENCRYPTION_KEY'] ?? getenv('ENCRYPTION_KEY') ?: ''); + if (trim($keyMaterial) === '') { + throw new RuntimeException('ENCRYPTION_KEY is required for replication secret encryption.'); + } + + return hash('sha256', $keyMaterial, true); + } +} diff --git a/services/nginx/app/classes/response.php b/services/nginx/app/classes/response.php index c4ecbb91..7262ebb7 100644 --- a/services/nginx/app/classes/response.php +++ b/services/nginx/app/classes/response.php @@ -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 diff --git a/services/nginx/app/classes/selfserve_schema_bootstrap.php b/services/nginx/app/classes/selfserve_schema_bootstrap.php index 015e1109..d67b4f41 100644 --- a/services/nginx/app/classes/selfserve_schema_bootstrap.php +++ b/services/nginx/app/classes/selfserve_schema_bootstrap.php @@ -114,6 +114,53 @@ class selfserve_schema_bootstrap INDEX idx_selfserve_wash_session_events_session (session_id), INDEX idx_selfserve_wash_session_events_type (event_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + + "CREATE TABLE IF NOT EXISTS department_selfserve_studio_layouts ( + id INT AUTO_INCREMENT PRIMARY KEY, + department_id INT NOT NULL, + user_id INT NULL, + layout_json JSON NOT 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_department_selfserve_studio_layouts_department_user (department_id, user_id), + INDEX idx_department_selfserve_studio_layouts_department_updated (department_id, updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + + "CREATE TABLE IF NOT EXISTS department_selfserve_studio_virtual_hardware ( + id INT AUTO_INCREMENT PRIMARY KEY, + department_id INT NOT NULL, + config_json JSON NOT NULL, + created_by INT NULL, + updated_by INT 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, + 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) { @@ -125,6 +172,11 @@ class selfserve_schema_bootstrap 'machine_type_id', 'ALTER TABLE department_lanes ADD COLUMN machine_type_id INT NULL AFTER dynamic_image_id' ); + self::ensureColumn( + 'department_lanes', + 'selfserve_enabled', + 'ALTER TABLE department_lanes ADD COLUMN selfserve_enabled TINYINT(1) NOT NULL DEFAULT 1 AFTER machine_type_id' + ); self::ensureColumn( 'department_selfserve_conditions', 'machine_type_id', diff --git a/services/nginx/app/classes/shelly.php b/services/nginx/app/classes/shelly.php index b18d0b61..9967d9e6 100644 --- a/services/nginx/app/classes/shelly.php +++ b/services/nginx/app/classes/shelly.php @@ -16,6 +16,10 @@ class shelly implements shelly_i private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20; private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000; private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate'; + /** + * @var array> + */ + private static array $blocked_request_log = []; /** * Configuration of the shelly module @@ -38,6 +42,19 @@ class shelly implements shelly_i $this->shelly_search = new shelly_search_a(); } + public static function resetBlockedRequestLog(): void + { + self::$blocked_request_log = []; + } + + /** + * @return array> + */ + public static function blockedRequestLog(): array + { + return self::$blocked_request_log; + } + /** * @inheritDoc * @throws Exception If the module is not enabled, the license plate is invalid, the daily limit is exceeded, or the secret key is invalid @@ -146,6 +163,7 @@ class shelly implements shelly_i * -H 'Content-Type: application/json' \ * -d '' */ + $this->guardRealShellyRequest('POST', $endpoint, $data); // Require the module to be enabled self::requireModuleEnabled(); self::requireValidSecretKey(); @@ -192,6 +210,7 @@ class shelly implements shelly_i */ function sendGetRequest(string $endpoint, array $data): array|object|null { + $this->guardRealShellyRequest('GET', $endpoint, $data); self::requireModuleEnabled(); self::requireValidSecretKey(); self::requireValidServerURL(); @@ -238,6 +257,40 @@ class shelly implements shelly_i return $url . $separator . http_build_query($query); } + /** + * @param array $data + * @throws Exception + */ + private function guardRealShellyRequest(string $method, string $endpoint, array $data): void + { + if (!$this->shouldBlockRealShellyRequest()) { + return; + } + + $record = [ + 'method' => strtoupper($method), + 'endpoint' => $endpoint, + 'data' => $data, + 'at' => date('c'), + ]; + self::$blocked_request_log[] = $record; + + $log_path = trim((string)(getenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG') ?: '')); + if ($log_path !== '') { + $encoded = json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (is_string($encoded)) { + @file_put_contents($log_path, $encoded . PHP_EOL, FILE_APPEND | LOCK_EX); + } + } + + throw new Exception('Real Shelly requests are blocked in test mode: ' . strtoupper($method) . ' ' . $endpoint); + } + + private function shouldBlockRealShellyRequest(): bool + { + return trim((string)(getenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY') ?: '')) === '1'; + } + /** * @throws Exception */ diff --git a/services/nginx/app/classes/superuser_system_status_service.php b/services/nginx/app/classes/superuser_system_status_service.php index 2e11641d..9a20b459 100644 --- a/services/nginx/app/classes/superuser_system_status_service.php +++ b/services/nginx/app/classes/superuser_system_status_service.php @@ -27,6 +27,9 @@ class superuser_system_status_service $dependencies['database']['status'] ?? 'down', $dependencies['redis']['status'] ?? 'down', $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) { @@ -295,6 +298,16 @@ class superuser_system_status_service $database = $this->probeDatabase(); $redis = $this->probeRedis(); $minio = $this->probeMinio(); + try { + $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') { $this->pushWarning( @@ -320,6 +333,19 @@ class superuser_system_status_service ]; } + private function replicationStatusFallback(string $kind, Throwable $throwable): array + { + return [ + 'status' => 'degraded', + 'min_percent' => 0.0, + 'average_percent' => 0.0, + 'replicas' => [], + 'blockers' => [ + 'Replication status for ' . $kind . ' could not be loaded: ' . $throwable->getMessage(), + ], + ]; + } + private function probeCpu(array &$warnings): array { $checkedAt = date('c'); @@ -503,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 @@ -816,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'] ?? '')); diff --git a/services/nginx/app/classes/workfeed_employee_name_formatter.php b/services/nginx/app/classes/workfeed_employee_name_formatter.php new file mode 100644 index 00000000..db549ff8 --- /dev/null +++ b/services/nginx/app/classes/workfeed_employee_name_formatter.php @@ -0,0 +1,103 @@ + $firstNamePaths + * @param array $lastNamePaths + * @param array $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 $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; + } +} diff --git a/services/nginx/app/classes/workfeed_shift_time_resolver.php b/services/nginx/app/classes/workfeed_shift_time_resolver.php index 64983ff0..e35e615a 100644 --- a/services/nginx/app/classes/workfeed_shift_time_resolver.php +++ b/services/nginx/app/classes/workfeed_shift_time_resolver.php @@ -21,16 +21,27 @@ 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', 'actualStart', 'actualStartTime', 'clockIn', 'clockInTime', - 'start', - 'startTime', - 'from', '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', @@ -38,17 +49,33 @@ final class workfeed_shift_time_resolver 'to', ]); $actual_only_end = self::firstDateTimeFromPaths($record, [ + 'checkOut.time', + 'checkOut', 'actualEnd', 'actualEndTime', 'clockOut', 'clockOutTime', ]); - $current_end = self::firstDateTimeFromPaths($record, [ - 'end', - 'endTime', - 'to', + $check_in_punch = self::firstDateTimeFromPaths($record, [ + 'checkIn.time', + 'checkIn', ]); - $saved_actual_end = $actual_only_end ?? $current_end; + $check_out_punch = self::firstDateTimeFromPaths($record, [ + 'checkOut.time', + '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(); + } if ($actual_start === null || $scheduled_end === null || $saved_actual_end === null) { return null; @@ -63,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, ]; } diff --git a/services/nginx/app/classes/xlvask_automation_service.php b/services/nginx/app/classes/xlvask_automation_service.php new file mode 100644 index 00000000..cee9dcb9 --- /dev/null +++ b/services/nginx/app/classes/xlvask_automation_service.php @@ -0,0 +1,1394 @@ +loadUsageLogRow($usageLogId); + if ($row === null) { + return $this->emptyAutomation('XL Vask-vasken blev ikke fundet.'); + } + + return $this->evaluateUsageLogRow($row, $actorId, $allowExecute); + } + + public function evaluateUsageLogRow(array $row, ?int $actorId = null, bool $allowExecute = true): array + { + $usageLogId = (int)($row['id'] ?? 0); + if ($usageLogId < 1) { + return $this->emptyAutomation('XL Vask-vasken mangler et gyldigt id.'); + } + + try { + $log = $this->usageLogFromRow($row); + $guard = $this->guardReason($log); + $existing = $this->latestTerminalSuggestion($usageLogId); + if ($guard !== null) { + if ($existing !== null && in_array((string)$existing['status'], [ + self::STATUS_AUTO_ACCEPTED, + self::STATUS_ACCEPTED, + self::STATUS_DENIED, + ], true)) { + return $this->formatSuggestion($existing); + } + + return $this->emptyAutomation($guard); + } + + if ($existing !== null) { + if ((string)$existing['status'] === self::STATUS_SUGGESTED) { + $context = $this->buildContext($usageLogId, $log); + $contextGuard = $this->contextGuardReason($context); + if ($contextGuard !== null) { + return $this->emptyAutomation($contextGuard); + } + + $freshSuggestion = $this->buildSuggestionForContext($context); + if ($freshSuggestion !== null) { + $suggestionId = $this->persistSuggestion($context, $freshSuggestion, $actorId); + $existing = $this->loadSuggestion($suggestionId) ?? $existing; + } + + if ($allowExecute && $this->shouldAutoExecute($existing, $context)) { + return $this->executeSuggestion($existing, $context, $actorId, true); + } + } + + return $this->formatSuggestion($existing); + } + + $context = $this->buildContext($usageLogId, $log); + $contextGuard = $this->contextGuardReason($context); + if ($contextGuard !== null) { + return $this->emptyAutomation($contextGuard); + } + + if ($this->hasDeniedFeedback($context['signature_hash'], self::ACTION_ATTACH) + && $this->hasDeniedFeedback($context['signature_hash'], self::ACTION_CREATE)) { + return $this->emptyAutomation('Tidligere afvist for samme køretøjsmønster.'); + } + + $suggestion = $this->buildSuggestionForContext($context); + + if ($suggestion === null || $suggestion['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) { + return $this->emptyAutomation('Ingen sikker automatiseringshandling fundet.'); + } + + if ($this->hasDeniedFeedback($context['signature_hash'], $suggestion['action'])) { + return $this->emptyAutomation('Tidligere afvist for samme køretøjsmønster.'); + } + + $suggestionId = $this->persistSuggestion($context, $suggestion, $actorId); + $suggestionRow = $this->loadSuggestion($suggestionId); + if ($suggestionRow === null) { + return $this->emptyAutomation('Forslaget kunne ikke gemmes.'); + } + + if ($allowExecute && $this->shouldAutoExecute($suggestionRow, $context)) { + return $this->executeSuggestion($suggestionRow, $context, $actorId, true); + } + + return $this->formatSuggestion($suggestionRow); + } catch (Exception $e) { + return [ + ...$this->emptyAutomation('Automatiseringen kunne ikke evaluere vasken.'), + 'status' => self::STATUS_FAILED, + 'error' => $e->getMessage(), + ]; + } + } + + public function acceptUsageLogById(int $usageLogId, ?int $actorId = null, ?int $suggestionId = null, ?string $reason = null): array + { + $row = $this->loadUsageLogRow($usageLogId); + if ($row === null) { + return $this->emptyAutomation('XL Vask-vasken blev ikke fundet.'); + } + + $log = $this->usageLogFromRow($row); + $guard = $this->guardReason($log); + if ($guard !== null) { + return $this->emptyAutomation($guard); + } + + $context = $this->buildContext($usageLogId, $log); + $contextGuard = $this->contextGuardReason($context); + if ($contextGuard !== null) { + return $this->emptyAutomation($contextGuard); + } + + $suggestion = $suggestionId !== null ? $this->loadSuggestion($suggestionId) : $this->latestActionableSuggestion($usageLogId); + if ($suggestion === null) { + $this->evaluateUsageLogRow($row, $actorId, false); + $suggestion = $this->latestActionableSuggestion($usageLogId); + } + + if ($suggestion === null) { + return $this->emptyAutomation('Der er intet forslag at acceptere.'); + } + + $result = $this->executeSuggestion($suggestion, $context, $actorId, false); + $this->persistFeedback($context, (string)$suggestion['action'], 'accepted', (int)($result['matched_order_id'] ?? $result['created_order_id'] ?? 0), $actorId, $reason); + + return $result; + } + + public function denyUsageLogById(int $usageLogId, ?int $actorId = null, ?int $suggestionId = null, ?string $reason = null): array + { + $row = $this->loadUsageLogRow($usageLogId); + if ($row === null) { + return $this->emptyAutomation('XL Vask-vasken blev ikke fundet.'); + } + + $log = $this->usageLogFromRow($row); + $context = $this->buildContext($usageLogId, $log); + $suggestion = $suggestionId !== null ? $this->loadSuggestion($suggestionId) : $this->latestActionableSuggestion($usageLogId); + + if ($suggestion === null) { + return $this->emptyAutomation('Der er intet forslag at afvise.'); + } + + $this->updateSuggestionStatus((int)$suggestion['id'], self::STATUS_DENIED, $actorId); + $this->persistFeedback($context, (string)$suggestion['action'], 'denied', (int)($suggestion['matched_order_id'] ?? 0), $actorId, $reason); + + return $this->formatSuggestion($this->loadSuggestion((int)$suggestion['id']) ?? $suggestion); + } + + public function runPending(?string $dateFrom = null, ?string $dateTo = null, array $ids = [], int $limit = 100, ?int $actorId = null): array + { + $rows = $ids !== [] ? $this->loadUsageLogRowsByIds($ids) : $this->loadPendingRows($dateFrom, $dateTo, $limit); + $results = []; + foreach ($rows as $row) { + $results[] = $this->evaluateUsageLogRow($row, $actorId, true); + } + + return [ + 'processed' => count($results), + 'results' => $results, + ]; + } + + public static function normalizeRegistrationForAutomation(string $registration): string + { + return strtoupper(preg_replace('/[^A-Z0-9]/i', '', $registration) ?? ''); + } + + public static function itemSignaturePartsForAutomation(array $items): array + { + $parts = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $parts[] = implode(':', [ + (int)($item['product_id'] ?? 0), + (int)($item['quantity'] ?? 0), + (int)($item['price'] ?? 0), + ]); + } + + sort($parts, SORT_STRING); + return $parts; + } + + public static function scoreItemMatchForAutomation(array $usageItems, array $orderItems): array + { + $usageSignature = self::itemSignaturePartsForAutomation($usageItems); + $orderSignature = self::itemSignaturePartsForAutomation($orderItems); + $usageTotal = self::itemsTotalForAutomation($usageItems); + $orderTotal = self::itemsTotalForAutomation($orderItems); + + if ($usageSignature === $orderSignature && $usageTotal === $orderTotal) { + return [ + 'confidence' => 0.95, + 'source' => self::SOURCE_DETERMINISTIC, + 'reason' => 'Produkterne og prisen matcher en ordre fra samme dag.', + ]; + } + + $usagePrimary = (int)($usageItems[0]['product_id'] ?? 0); + $orderPrimary = (int)($orderItems[0]['product_id'] ?? 0); + if ($usagePrimary < 1 || $usagePrimary !== $orderPrimary) { + return ['confidence' => 0.0, 'source' => self::SOURCE_DETERMINISTIC, 'reason' => '']; + } + + $overlap = self::productOverlapForAutomation($usageItems, $orderItems); + $totalDiff = abs($usageTotal - $orderTotal); + if ($overlap >= 0.70 && $totalDiff <= 50) { + return [ + 'confidence' => 0.93, + 'source' => self::SOURCE_FUZZY, + 'reason' => 'Samme primære produkt og relaterede tilføjelser matcher en ordre fra samme dag.', + ]; + } + + if ($overlap >= 0.50 && $totalDiff <= 150) { + return [ + 'confidence' => 0.80, + 'source' => self::SOURCE_FUZZY, + 'reason' => 'Vasken ligner en ordre fra samme dag, men kræver manuel godkendelse.', + ]; + } + + $matchableUsageItems = self::matchableUsageItemsForAutomation($usageItems); + $matchableOverlap = self::productOverlapForAutomation($matchableUsageItems, $orderItems); + if ( + $matchableUsageItems !== [] + && $matchableOverlap >= 0.95 + && self::orderHasAdditionsBeyondUsage($matchableUsageItems, $orderItems) + ) { + return [ + 'confidence' => 0.88, + 'source' => self::SOURCE_FUZZY, + 'reason' => 'Ordren indeholder XL Vask-produkterne samt ekstra ydelser fra samme dag.', + ]; + } + + return ['confidence' => 0.0, 'source' => self::SOURCE_DETERMINISTIC, 'reason' => '']; + } + + public static function itemsTotalForAutomation(array $items): int + { + return array_reduce($items, fn(int $total, array $item): int => $total + ((int)($item['price'] ?? 0) * (int)($item['quantity'] ?? 0)), 0); + } + + public static function productOverlapForAutomation(array $usageItems, array $orderItems): float + { + $usageBag = self::productBagForAutomation($usageItems); + $orderBag = self::productBagForAutomation($orderItems); + $usageTotal = array_sum($usageBag); + if ($usageTotal <= 0) { + return 0.0; + } + + $overlap = 0; + foreach ($usageBag as $productId => $quantity) { + $overlap += min($quantity, $orderBag[$productId] ?? 0); + } + + return $overlap / $usageTotal; + } + + public static function productBagForAutomation(array $items): array + { + $bag = []; + foreach ($items as $item) { + $productId = (int)($item['product_id'] ?? 0); + if ($productId < 1) { + continue; + } + $bag[$productId] = ($bag[$productId] ?? 0) + max(1, (int)($item['quantity'] ?? 1)); + } + + return $bag; + } + + private static function matchableUsageItemsForAutomation(array $items): array + { + $positiveItems = array_values(array_filter($items, static function (array $item): bool { + return (int)($item['product_id'] ?? 0) > 0 + && (int)($item['quantity'] ?? 0) > 0 + && (int)($item['price'] ?? 0) > 0; + })); + + if ($positiveItems !== []) { + return $positiveItems; + } + + return array_values(array_filter($items, static function (array $item): bool { + return (int)($item['product_id'] ?? 0) > 0 + && (int)($item['quantity'] ?? 0) > 0; + })); + } + + private static function orderHasAdditionsBeyondUsage(array $usageItems, array $orderItems): bool + { + $usageBag = self::productBagForAutomation($usageItems); + foreach (self::productBagForAutomation($orderItems) as $productId => $quantity) { + if ($quantity > ($usageBag[$productId] ?? 0)) { + return true; + } + } + + return false; + } + + public static function normalizeUsageLogRowForAutomation(array $row): array + { + unset($row['id']); + + $washItems = $row['WashItems'] ?? []; + if (is_string($washItems)) { + $decoded = json_decode($washItems, true); + $row['WashItems'] = is_array($decoded) ? $decoded : []; + } elseif (!is_array($washItems)) { + $row['WashItems'] = []; + } + + return $row; + } + + public static function openAiCacheKeyForAutomation( + string $schemaName, + string $prompt, + array $payload, + array $schema, + float $temperature + ): string { + $input = [ + 'version' => self::OPENAI_CACHE_VERSION, + 'schema_name' => $schemaName, + 'prompt' => $prompt, + 'payload' => $payload, + 'schema' => $schema, + 'temperature' => round($temperature, 4), + ]; + + return hash('sha256', self::stableJsonForAutomation($input)); + } + + public static function stableJsonForAutomation(mixed $value): string + { + $encoded = json_encode( + self::normalizeForStableJson($value), + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION + ); + + if ($encoded === false) { + throw new Exception('Kunne ikke opbygge en stabil cache-nøgle for XL Vask-automatisering.'); + } + + return $encoded; + } + + private static function normalizeForStableJson(mixed $value): mixed + { + if (!is_array($value)) { + return $value; + } + + $normalized = array_map(fn(mixed $item): mixed => self::normalizeForStableJson($item), $value); + $isList = $normalized === [] || array_keys($normalized) === range(0, count($normalized) - 1); + if (!$isList) { + ksort($normalized, SORT_STRING); + } + + return $normalized; + } + + private function buildDeterministicSuggestion(array $context): ?array + { + $best = null; + foreach ($context['candidate_orders'] as $candidate) { + $score = $this->scoreOrderMatch($context['items'], $candidate['order_items']); + if ($score['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) { + continue; + } + + $candidateSuggestion = [ + 'action' => self::ACTION_ATTACH, + 'confidence' => $score['confidence'], + 'source' => $score['source'], + 'matched_order_id' => (int)$candidate['id'], + 'created_order_id' => null, + 'candidate_order' => $candidate, + 'proposed_order' => $context['proposed_order'], + 'reason' => $score['reason'] . ' Ordre #' . (int)$candidate['id'] . '.', + ]; + + if ($best === null || $candidateSuggestion['confidence'] > $best['confidence']) { + $best = $candidateSuggestion; + } + } + + if ($best !== null && $this->hasAcceptedFeedback($context['signature_hash'], self::ACTION_ATTACH)) { + $best['confidence'] = max($best['confidence'], 0.96); + $best['source'] = self::SOURCE_HISTORY; + $best['reason'] = 'Tidligere godkendt mønster for køretøjet matcher ordre #' . (int)$best['matched_order_id'] . '.'; + } + + if ($best !== null) { + return $best; + } + + if ($context['age_hours'] >= self::CREATE_MIN_AGE_HOURS) { + $history = $this->findMatchingHistoricalOrder($context); + if ($history !== null || $this->hasAcceptedFeedback($context['signature_hash'], self::ACTION_CREATE)) { + return [ + 'action' => self::ACTION_CREATE, + 'confidence' => 0.98, + 'source' => self::SOURCE_HISTORY, + 'matched_order_id' => null, + 'created_order_id' => null, + 'candidate_order' => $history, + 'proposed_order' => $context['proposed_order'], + 'reason' => 'Vasken er over 6 timer gammel og matcher et tidligere godkendt køretøjsmønster.', + ]; + } + } + + return null; + } + + private function buildSuggestionForContext(array $context): ?array + { + $suggestion = $this->buildDeterministicSuggestion($context); + + if ( + ($suggestion === null || $suggestion['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) + && $this->isOpenAiEnabled() + ) { + $suggestion = $this->buildOpenAiSuggestion($context) ?? $suggestion; + } + + if ($suggestion === null || $suggestion['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) { + return null; + } + + if ($this->hasDeniedFeedback($context['signature_hash'], $suggestion['action'])) { + return null; + } + + return $suggestion; + } + + private function buildOpenAiSuggestion(array $context): ?array + { + try { + $schemaName = 'xlvask_automation'; + $prompt = 'Vurder om en XL Vask-vask skal tilknyttes en eksisterende ordre, oprettes som ordre eller ikke behandles. Returner kun JSON efter skemaet.'; + $temperature = 0.1; + $schema = [ + 'type' => 'object', + 'properties' => [ + 'action' => ['type' => 'string', 'enum' => [self::ACTION_ATTACH, self::ACTION_CREATE, self::ACTION_NONE]], + 'confidence' => ['type' => 'number'], + 'reason_da' => ['type' => 'string'], + 'candidate_order_id' => ['type' => ['integer', 'null']], + 'proposed_order_items' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'product_id' => ['type' => 'integer'], + 'quantity' => ['type' => 'integer'], + 'price' => ['type' => 'integer'], + ], + 'required' => ['product_id', 'quantity', 'price'], + 'additionalProperties' => false, + ], + ], + 'risk_flags' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['action', 'confidence', 'reason_da', 'candidate_order_id', 'proposed_order_items', 'risk_flags'], + 'additionalProperties' => false, + ]; + + $creationAllowed = $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS; + $payload = [ + 'usage_log' => [ + 'wash_id' => $context['wash_id'], + 'registration' => $context['signature']['registration'], + 'customer_number' => $context['signature']['customer_number'], + 'department_id' => $context['signature']['department_id'], + 'lane' => $context['signature']['lane'], + 'created_at' => $context['proposed_order']['created_at'] ?? null, + 'total_net_amount' => $context['total'], + 'items' => $this->compactItems($context['items']), + 'creation_allowed' => $creationAllowed, + 'age_bucket' => $creationAllowed ? 'older_than_6_hours' : 'newer_than_6_hours', + ], + 'candidate_orders' => array_map(fn(array $candidate): array => [ + 'id' => (int)$candidate['id'], + 'created_at' => $candidate['created_at'] ?? null, + 'total_net_amount' => (int)($candidate['total_net_amount'] ?? 0), + 'items' => $this->compactItems($candidate['order_items'] ?? []), + ], $context['candidate_orders']), + ]; + + $cacheKey = self::openAiCacheKeyForAutomation($schemaName, $prompt, $payload, $schema, $temperature); + $result = $this->loadOpenAiCacheResult($cacheKey); + if ($result === null) { + $openai = new openai(); + $result = $openai->jsonTask($schemaName, $prompt, $payload, $schema, $temperature); + $this->persistOpenAiCacheResult($cacheKey, $schemaName, $payload, $schema, $prompt, $temperature, $result); + } + + $action = (string)($result['action'] ?? self::ACTION_NONE); + $confidence = (float)($result['confidence'] ?? 0); + if (!in_array($action, [self::ACTION_ATTACH, self::ACTION_CREATE], true) || $confidence < self::MIN_SUGGESTION_CONFIDENCE) { + return null; + } + + if ($action === self::ACTION_CREATE && $context['age_hours'] < self::CREATE_MIN_AGE_HOURS) { + return null; + } + + $candidate = null; + $candidateOrderId = (int)($result['candidate_order_id'] ?? 0); + if ($action === self::ACTION_ATTACH) { + foreach ($context['candidate_orders'] as $candidateOrder) { + if ((int)$candidateOrder['id'] === $candidateOrderId) { + $candidate = $candidateOrder; + break; + } + } + if ($candidate === null) { + return null; + } + } + + return [ + 'action' => $action, + 'confidence' => min(1.0, max(0.0, $confidence)), + 'source' => self::SOURCE_OPENAI, + 'matched_order_id' => $candidateOrderId > 0 ? $candidateOrderId : null, + 'created_order_id' => null, + 'candidate_order' => $candidate, + 'proposed_order' => $context['proposed_order'], + 'reason' => (string)($result['reason_da'] ?? 'OpenAI foreslår handlingen ud fra tilgængelige ordredata.'), + ]; + } catch (Exception) { + return null; + } + } + + private function shouldAutoExecute(array $suggestion, array $context): bool + { + $confidence = (float)$suggestion['confidence']; + $action = (string)$suggestion['action']; + $xlvask = new xlvask(); + + if ($action === self::ACTION_ATTACH) { + return $xlvask->config->automatic_order_attachment_enabled->isTrue() + && $confidence >= self::AUTO_ATTACH_CONFIDENCE; + } + + if ($action === self::ACTION_CREATE) { + return $xlvask->config->automatic_order_creation_enabled->isTrue() + && $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS + && $confidence >= self::AUTO_CREATE_CONFIDENCE; + } + + return false; + } + + private function executeSuggestion(array $suggestion, array $context, ?int $actorId, bool $automatic): array + { + try { + $action = (string)$suggestion['action']; + if ($action === self::ACTION_ATTACH) { + $orderId = (int)$suggestion['matched_order_id']; + if ($orderId < 1) { + throw new Exception('Forslaget mangler en ordre at tilknytte.'); + } + + if ((new orders_o())->selectByWashId($context['wash_id']) !== null) { + throw new Exception('Vasken er allerede tilknyttet en ordre.'); + } + + $order = (new orders_o())->select($orderId); + $order->wash_id->set($context['wash_id']); + $this->updateSuggestionExecution((int)$suggestion['id'], $automatic ? self::STATUS_AUTO_ACCEPTED : self::STATUS_ACCEPTED, $actorId, $orderId, null); + } elseif ($action === self::ACTION_CREATE) { + if ($context['age_hours'] < self::CREATE_MIN_AGE_HOURS) { + throw new Exception('Vasken er ikke gammel nok til automatisk ordreoprettelse.'); + } + + if ((new orders_o())->selectByWashId($context['wash_id']) !== null) { + throw new Exception('Vasken er allerede tilknyttet en ordre.'); + } + + $order = $this->createOrderFromContext($context); + $this->updateSuggestionExecution((int)$suggestion['id'], $automatic ? self::STATUS_AUTO_ACCEPTED : self::STATUS_ACCEPTED, $actorId, null, (int)$order->id); + } else { + throw new Exception('Ukendt automatiseringshandling.'); + } + + $latest = $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion; + if ($automatic) { + $this->persistFeedback($context, $action, 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, 'Automatisk accepteret.'); + } + + return $this->formatSuggestion($latest); + } catch (Exception $e) { + $this->updateSuggestionFailure((int)$suggestion['id'], $e->getMessage(), $actorId); + return [ + ...$this->formatSuggestion($this->loadSuggestion((int)$suggestion['id']) ?? $suggestion), + 'status' => self::STATUS_FAILED, + 'error' => $e->getMessage(), + ]; + } + } + + private function createOrderFromContext(array $context): orders_o + { + $orderData = $context['proposed_order']; + $items = $context['items']; + + $order = new orders_o(); + $order->add( + (int)$orderData['customer_id'], + self::AUTOMATION_CASHIER_ID, + (string)($orderData['reference'] ?? ''), + (string)($orderData['notes'] ?? ''), + (int)$orderData['department_id'], + (string)($orderData['reg_1'] ?? ''), + (string)($orderData['reg_2'] ?? ''), + (string)($orderData['reg_3'] ?? '') + ); + $order->wash_id->set($context['wash_id']); + if (isset($orderData['lane'])) { + $order->lane->set((int)$orderData['lane']); + } + if (!empty($orderData['created_at'])) { + $order->created_at->set((string)$orderData['created_at']); + } + + $firstItemId = null; + foreach ($items as $item) { + $orderItem = new order_items_o(); + $orderItem->add( + (int)$order->id, + (int)$item['product_id'], + (string)($item['reference'] ?? ''), + (string)($item['notes'] ?? ''), + self::AUTOMATION_CASHIER_ID, + (int)$item['price'], + (int)$item['quantity'], + $firstItemId + ); + if ($firstItemId === null) { + $firstItemId = (int)$orderItem->id; + } + } + + $order->objectChanged(); + return $order; + } + + private function buildContext(int $usageLogId, xlvask_usage_log $log): array + { + $simulated = (new orders_o())->simulateOrderFromXLVask($log, true); + $proposedOrder = $simulated['order'] ?? []; + $items = $simulated['order_items'] ?? []; + $signature = $this->buildSignature($log, $proposedOrder, $items); + $signatureJson = json_encode($signature, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($signatureJson === false) { + throw new Exception('Kunne ikke opbygge signatur for XL Vask-vasken.'); + } + + return [ + 'usage_log_id' => $usageLogId, + 'wash_id' => (string)$log->WashId, + 'log' => $log, + 'proposed_order' => $proposedOrder, + 'items' => $items, + 'total' => $this->itemsTotal($items), + 'signature' => $signature, + 'signature_json' => $signatureJson, + 'signature_hash' => hash('sha256', $signatureJson), + 'age_hours' => max(0.0, (time() - strtotime((string)$log->StartTime)) / 3600), + 'candidate_orders' => $this->findSameDayCandidateOrders($log, $proposedOrder), + ]; + } + + private function contextGuardReason(array $context): ?string + { + if ((int)($context['proposed_order']['customer_id'] ?? 0) < 1) { + return 'Vasken mangler en gyldig kundemapping.'; + } + + if ((int)($context['proposed_order']['department_id'] ?? 0) < 1) { + return 'Vasken mangler en gyldig afdelingsmapping.'; + } + + if (!is_array($context['items'] ?? null) || count($context['items']) < 1) { + return 'Vasken mangler gyldige produkter.'; + } + + foreach ($context['items'] as $item) { + if (!is_array($item) || (int)($item['product_id'] ?? 0) < 1) { + return 'Vasken mangler gyldige produkter.'; + } + } + + return null; + } + + private function buildSignature(xlvask_usage_log $log, array $proposedOrder, array $items): array + { + return [ + 'registration' => $this->normalizeRegistration((string)$log->RegistrationNumber), + 'customer_number' => (int)$log->CustomerId, + 'department_id' => (int)($proposedOrder['department_id'] ?? 0), + 'lane' => (int)($proposedOrder['lane'] ?? 0), + 'primary_product_id' => (int)($items[0]['product_id'] ?? 0), + 'items' => $this->itemSignatureParts($items), + 'total_net_amount' => $this->itemsTotal($items), + ]; + } + + private function scoreOrderMatch(array $usageItems, array $orderItems): array + { + return self::scoreItemMatchForAutomation($usageItems, $orderItems); + + $usageSignature = $this->itemSignatureParts($usageItems); + $orderSignature = $this->itemSignatureParts($orderItems); + $usageTotal = $this->itemsTotal($usageItems); + $orderTotal = $this->itemsTotal($orderItems); + + if ($usageSignature === $orderSignature && $usageTotal === $orderTotal) { + return [ + 'confidence' => 0.95, + 'source' => self::SOURCE_DETERMINISTIC, + 'reason' => 'Produkterne og prisen matcher en ordre fra samme dag.', + ]; + } + + $usagePrimary = (int)($usageItems[0]['product_id'] ?? 0); + $orderPrimary = (int)($orderItems[0]['product_id'] ?? 0); + if ($usagePrimary < 1 || $usagePrimary !== $orderPrimary) { + return ['confidence' => 0.0, 'source' => self::SOURCE_DETERMINISTIC, 'reason' => '']; + } + + $overlap = $this->productOverlap($usageItems, $orderItems); + $totalDiff = abs($usageTotal - $orderTotal); + if ($overlap >= 0.70 && $totalDiff <= 50) { + return [ + 'confidence' => 0.93, + 'source' => self::SOURCE_FUZZY, + 'reason' => 'Samme primære produkt og relaterede tillæg matcher en ordre fra samme dag.', + ]; + } + + if ($overlap >= 0.50 && $totalDiff <= 150) { + return [ + 'confidence' => 0.80, + 'source' => self::SOURCE_FUZZY, + 'reason' => 'Vasken ligner en ordre fra samme dag, men kræver manuel godkendelse.', + ]; + } + + return ['confidence' => 0.0, 'source' => self::SOURCE_DETERMINISTIC, 'reason' => '']; + } + + private function findSameDayCandidateOrders(xlvask_usage_log $log, array $proposedOrder): array + { + global $db; + + $registration = $db->escape_string($this->normalizeRegistration((string)$log->RegistrationNumber)); + $rawRegistration = $db->escape_string(trim((string)$log->RegistrationNumber)); + $customerNumber = (int)$log->CustomerId; + $departmentId = (int)($proposedOrder['department_id'] ?? 0); + $date = date('Y-m-d', strtotime((string)$log->StartTime)); + $from = $db->escape_string($date . ' 00:00:00'); + $to = $db->escape_string($date . ' 23:59:59'); + + if ($registration === '' || $customerNumber < 1 || $departmentId < 1) { + return []; + } + + $sql = "SELECT * + FROM orders + WHERE deleted_at IS NULL + AND customer_id = {$customerNumber} + AND department_id = {$departmentId} + AND cashier_id <> " . self::AUTOMATION_CASHIER_ID . " + AND created_at BETWEEN '{$from}' AND '{$to}' + AND (wash_id IS NULL OR wash_id = '') + AND ( + REPLACE(UPPER(reg_1), ' ', '') IN ('{$registration}', '{$rawRegistration}') + OR REPLACE(UPPER(reg_2), ' ', '') IN ('{$registration}', '{$rawRegistration}') + OR REPLACE(UPPER(reg_3), ' ', '') IN ('{$registration}', '{$rawRegistration}') + ) + ORDER BY ABS(TIMESTAMPDIFF(SECOND, created_at, '" . $db->escape_string(date('Y-m-d H:i:s', strtotime((string)$log->StartTime))) . "')) ASC + LIMIT 20"; + + $rows = $db->fetch_all($db->query($sql)); + return array_map(function (array $row): array { + $orderItems = (new orders_o())->getOrderItems((int)$row['id']); + return [ + ...$row, + 'id' => (int)$row['id'], + 'total_net_amount' => (int)($row['total_net_amount'] ?? $this->itemsTotal($orderItems)), + 'order_items' => $orderItems, + ]; + }, $rows); + } + + private function findMatchingHistoricalOrder(array $context): ?array + { + global $db; + + $signature = $context['signature']; + $registration = $db->escape_string((string)$signature['registration']); + $customerNumber = (int)$signature['customer_number']; + $departmentId = (int)$signature['department_id']; + $createdBefore = $db->escape_string((string)($context['proposed_order']['created_at'] ?? date('Y-m-d H:i:s'))); + + if ($registration === '' || $customerNumber < 1 || $departmentId < 1) { + return null; + } + + $sql = "SELECT * + FROM orders + WHERE deleted_at IS NULL + AND customer_id = {$customerNumber} + AND department_id = {$departmentId} + AND created_at < '{$createdBefore}' + AND ( + REPLACE(UPPER(reg_1), ' ', '') = '{$registration}' + OR REPLACE(UPPER(reg_2), ' ', '') = '{$registration}' + OR REPLACE(UPPER(reg_3), ' ', '') = '{$registration}' + ) + ORDER BY created_at DESC + LIMIT 10"; + + foreach ($db->fetch_all($db->query($sql)) as $row) { + $orderItems = (new orders_o())->getOrderItems((int)$row['id']); + if ($this->itemSignatureParts($orderItems) === $signature['items']) { + return [ + ...$row, + 'id' => (int)$row['id'], + 'order_items' => $orderItems, + ]; + } + } + + return null; + } + + private function guardReason(xlvask_usage_log $log): ?string + { + if (!empty($log->ignored_at)) { + return 'Vasken er ignoreret.'; + } + + if (!$log->isCompleted()) { + return 'Vasken er ikke afsluttet.'; + } + + if (!$log->hasBillableCustomer()) { + return 'Vasken mangler en fakturerbar kunde.'; + } + + if ((new orders_o())->selectByWashId($log->WashId) !== null) { + return 'Vasken er allerede tilknyttet en ordre.'; + } + + return null; + } + + private function persistSuggestion(array $context, array $suggestion, ?int $actorId): int + { + global $db; + + $existing = $this->latestActionableSuggestion((int)$context['usage_log_id']); + if ($existing !== null) { + $this->updateSuggestionProposal((int)$existing['id'], $context, $suggestion, $actorId); + return (int)$existing['id']; + } + + $fields = [ + 'usage_log_id' => (int)$context['usage_log_id'], + 'wash_id' => (string)$context['wash_id'], + 'signature_hash' => (string)$context['signature_hash'], + 'signature_json' => (string)$context['signature_json'], + 'action' => (string)$suggestion['action'], + 'status' => self::STATUS_SUGGESTED, + 'confidence' => (float)$suggestion['confidence'], + 'source' => (string)$suggestion['source'], + 'matched_order_id' => $suggestion['matched_order_id'] === null ? null : (int)$suggestion['matched_order_id'], + 'created_order_id' => null, + 'proposed_order_json' => json_encode($suggestion['proposed_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'candidate_order_json' => json_encode($suggestion['candidate_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'reason' => (string)$suggestion['reason'], + 'created_by' => $actorId, + ]; + + $columns = []; + $values = []; + foreach ($fields as $column => $value) { + $columns[] = "`{$column}`"; + if ($value === null) { + $values[] = 'NULL'; + } elseif (is_int($value) || is_float($value)) { + $values[] = (string)$value; + } else { + $values[] = "'" . $db->escape_string((string)$value) . "'"; + } + } + + $db->query('INSERT INTO xlvask_automation_suggestions (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $values) . ')'); + return (int)$db->insert_id(); + } + + private function updateSuggestionProposal(int $suggestionId, array $context, array $suggestion, ?int $actorId): void + { + global $db; + + $fields = [ + 'signature_hash' => (string)$context['signature_hash'], + 'signature_json' => (string)$context['signature_json'], + 'action' => (string)$suggestion['action'], + 'confidence' => (float)$suggestion['confidence'], + 'source' => (string)$suggestion['source'], + 'matched_order_id' => $suggestion['matched_order_id'] === null ? null : (int)$suggestion['matched_order_id'], + 'created_order_id' => null, + 'proposed_order_json' => json_encode($suggestion['proposed_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'candidate_order_json' => json_encode($suggestion['candidate_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'reason' => (string)$suggestion['reason'], + ]; + + if ($actorId !== null) { + $fields['created_by'] = $actorId; + } + + $assignments = []; + foreach ($fields as $column => $value) { + if ($value === null) { + $sqlValue = 'NULL'; + } elseif (is_int($value) || is_float($value)) { + $sqlValue = (string)$value; + } else { + $sqlValue = "'" . $db->escape_string((string)$value) . "'"; + } + $assignments[] = "`{$column}` = {$sqlValue}"; + } + + $db->query( + 'UPDATE xlvask_automation_suggestions SET ' . implode(', ', $assignments) . + " WHERE id = {$suggestionId} AND status = '" . self::STATUS_SUGGESTED . "'" + ); + } + + private function persistFeedback(array $context, string $action, string $decision, int $orderId = 0, ?int $actorId = null, ?string $reason = null): void + { + global $db; + + $values = [ + 'usage_log_id' => (int)$context['usage_log_id'], + 'wash_id' => (string)$context['wash_id'], + 'signature_hash' => (string)$context['signature_hash'], + 'signature_json' => (string)$context['signature_json'], + 'action' => $action, + 'decision' => $decision, + 'order_id' => $orderId > 0 ? $orderId : null, + 'reason' => $reason, + 'created_by' => $actorId, + ]; + + $columns = []; + $sqlValues = []; + foreach ($values as $column => $value) { + $columns[] = "`{$column}`"; + if ($value === null) { + $sqlValues[] = 'NULL'; + } elseif (is_int($value)) { + $sqlValues[] = (string)$value; + } else { + $sqlValues[] = "'" . $db->escape_string((string)$value) . "'"; + } + } + + $db->query('INSERT INTO xlvask_automation_feedback (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $sqlValues) . ')'); + } + + private function loadOpenAiCacheResult(string $cacheKey): ?array + { + global $db; + + $cacheKey = $db->escape_string($cacheKey); + $result = $db->query( + "SELECT result_json FROM xlvask_automation_openai_cache + WHERE cache_key = '{$cacheKey}' + LIMIT 1" + ); + if ($result === false || $result->num_rows < 1) { + return null; + } + + $row = $db->fetch_assoc($result); + $decoded = json_decode((string)($row['result_json'] ?? ''), true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) { + return null; + } + + $db->query( + "UPDATE xlvask_automation_openai_cache + SET hits = hits + 1, last_hit_at = NOW() + WHERE cache_key = '{$cacheKey}'" + ); + + return $decoded; + } + + private function persistOpenAiCacheResult( + string $cacheKey, + string $schemaName, + array $payload, + array $schema, + string $prompt, + float $temperature, + array $result + ): void { + global $db; + + $input = [ + 'version' => self::OPENAI_CACHE_VERSION, + 'schema_name' => $schemaName, + 'prompt' => $prompt, + 'payload' => $payload, + 'schema' => $schema, + 'temperature' => round($temperature, 4), + ]; + + $inputJson = self::stableJsonForAutomation($input); + $resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION); + if ($resultJson === false) { + return; + } + + $cacheKey = $db->escape_string($cacheKey); + $schemaName = $db->escape_string($schemaName); + $inputJson = $db->escape_string($inputJson); + $resultJson = $db->escape_string($resultJson); + + $db->query( + "INSERT INTO xlvask_automation_openai_cache + (cache_key, schema_name, input_json, result_json) + VALUES + ('{$cacheKey}', '{$schemaName}', '{$inputJson}', '{$resultJson}') + ON DUPLICATE KEY UPDATE + result_json = VALUES(result_json), + input_json = VALUES(input_json), + updated_at = NOW()" + ); + } + + private function hasAcceptedFeedback(string $signatureHash, string $action): bool + { + return $this->hasFeedbackDecision($signatureHash, $action, 'accepted'); + } + + private function hasDeniedFeedback(string $signatureHash, string $action): bool + { + return $this->hasFeedbackDecision($signatureHash, $action, 'denied'); + } + + private function hasFeedbackDecision(string $signatureHash, string $action, string $decision): bool + { + global $db; + $signatureHash = $db->escape_string($signatureHash); + $action = $db->escape_string($action); + $decision = $db->escape_string($decision); + $result = $db->query( + "SELECT id FROM xlvask_automation_feedback + WHERE signature_hash = '{$signatureHash}' AND action = '{$action}' AND decision = '{$decision}' + ORDER BY id DESC LIMIT 1" + ); + return $result !== false && $result->num_rows > 0; + } + + private function latestTerminalSuggestion(int $usageLogId): ?array + { + return $this->latestSuggestionWhere($usageLogId, [ + self::STATUS_SUGGESTED, + self::STATUS_AUTO_ACCEPTED, + self::STATUS_ACCEPTED, + self::STATUS_DENIED, + ]); + } + + private function latestActionableSuggestion(int $usageLogId): ?array + { + return $this->latestSuggestionWhere($usageLogId, [self::STATUS_SUGGESTED]); + } + + private function latestSuggestionWhere(int $usageLogId, array $statuses): ?array + { + global $db; + $statusSql = implode(',', array_map(fn(string $status): string => "'" . $db->escape_string($status) . "'", $statuses)); + $result = $db->query( + "SELECT * FROM xlvask_automation_suggestions + WHERE usage_log_id = {$usageLogId} AND status IN ({$statusSql}) + ORDER BY id DESC LIMIT 1" + ); + if ($result === false || $result->num_rows < 1) { + return null; + } + + return $db->fetch_assoc($result); + } + + private function loadSuggestion(int $suggestionId): ?array + { + global $db; + $result = $db->query("SELECT * FROM xlvask_automation_suggestions WHERE id = {$suggestionId} LIMIT 1"); + if ($result === false || $result->num_rows < 1) { + return null; + } + return $db->fetch_assoc($result); + } + + private function updateSuggestionStatus(int $suggestionId, string $status, ?int $actorId): void + { + global $db; + $status = $db->escape_string($status); + $actorSql = $actorId === null ? 'NULL' : (string)(int)$actorId; + $db->query( + "UPDATE xlvask_automation_suggestions + SET status = '{$status}', decided_by = {$actorSql}, decided_at = NOW() + WHERE id = {$suggestionId}" + ); + } + + private function updateSuggestionExecution(int $suggestionId, string $status, ?int $actorId, ?int $matchedOrderId, ?int $createdOrderId): void + { + global $db; + $status = $db->escape_string($status); + $actorSql = $actorId === null ? 'NULL' : (string)(int)$actorId; + $matchedSql = $matchedOrderId === null ? 'matched_order_id' : (string)(int)$matchedOrderId; + $createdSql = $createdOrderId === null ? 'created_order_id' : (string)(int)$createdOrderId; + $db->query( + "UPDATE xlvask_automation_suggestions + SET status = '{$status}', + decided_by = {$actorSql}, + decided_at = NOW(), + executed_at = NOW(), + matched_order_id = {$matchedSql}, + created_order_id = {$createdSql} + WHERE id = {$suggestionId}" + ); + } + + private function updateSuggestionFailure(int $suggestionId, string $message, ?int $actorId): void + { + global $db; + $actorSql = $actorId === null ? 'NULL' : (string)(int)$actorId; + $message = $db->escape_string($message); + $db->query( + "UPDATE xlvask_automation_suggestions + SET status = '" . self::STATUS_FAILED . "', + reason = CONCAT(COALESCE(reason, ''), ' Fejl: {$message}'), + decided_by = {$actorSql}, + decided_at = NOW() + WHERE id = {$suggestionId}" + ); + } + + private function loadUsageLogRow(int $usageLogId): ?array + { + global $db; + (new xlvask_usage_logs_o())->structure(); + $result = $db->query("SELECT * FROM xlvask_usage_logs WHERE id = {$usageLogId} LIMIT 1"); + if ($result === false || $result->num_rows < 1) { + return null; + } + + return $db->fetch_assoc($result); + } + + private function loadUsageLogRowsByIds(array $ids): array + { + global $db; + $ids = array_values(array_filter(array_map('intval', $ids), fn(int $id): bool => $id > 0)); + if ($ids === []) { + return []; + } + + (new xlvask_usage_logs_o())->structure(); + $result = $db->query('SELECT * FROM xlvask_usage_logs WHERE id IN (' . implode(',', $ids) . ')'); + return $db->fetch_all($result); + } + + private function loadPendingRows(?string $dateFrom, ?string $dateTo, int $limit): array + { + global $db; + (new xlvask_usage_logs_o())->structure(); + $where = [ + 'FinishStatus = 1', + '(ignored_at IS NULL OR ignored_at = "")', + ]; + + if ($dateFrom !== null && strtotime($dateFrom) !== false) { + $where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'"; + } else { + $where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'"; + } + + if ($dateTo !== null && strtotime($dateTo) !== false) { + $where[] = "StartTime <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'"; + } + + $limit = max(1, min(500, $limit)); + $result = $db->query('SELECT * FROM xlvask_usage_logs WHERE ' . implode(' AND ', $where) . " ORDER BY StartTime DESC LIMIT {$limit}"); + return $db->fetch_all($result); + } + + private function usageLogFromRow(array $row): xlvask_usage_log + { + $row = self::normalizeUsageLogRowForAutomation($row); + + $xlvask = new xlvask(); + return $xlvask->new($xlvask->helpers->xlvask_usage_log)->setProperties($row); + } + + private function formatSuggestion(array $row): array + { + $status = (string)($row['status'] ?? self::STATUS_NONE); + return [ + 'id' => isset($row['id']) ? (int)$row['id'] : null, + 'status' => $status, + 'action' => (string)($row['action'] ?? self::ACTION_NONE), + 'confidence' => isset($row['confidence']) ? (float)$row['confidence'] : 0.0, + 'source' => (string)($row['source'] ?? ''), + 'reason' => (string)($row['reason'] ?? ''), + 'matched_order_id' => isset($row['matched_order_id']) && $row['matched_order_id'] !== null ? (int)$row['matched_order_id'] : null, + 'created_order_id' => isset($row['created_order_id']) && $row['created_order_id'] !== null ? (int)$row['created_order_id'] : null, + 'candidate_order' => $this->decodeJsonField($row['candidate_order_json'] ?? null), + 'proposed_order' => $this->decodeJsonField($row['proposed_order_json'] ?? null), + 'can_accept' => $status === self::STATUS_SUGGESTED, + 'can_deny' => $status === self::STATUS_SUGGESTED, + ]; + } + + private function emptyAutomation(string $reason = ''): array + { + return [ + 'id' => null, + 'status' => self::STATUS_NONE, + 'action' => self::ACTION_NONE, + 'confidence' => 0.0, + 'source' => '', + 'reason' => $reason, + 'matched_order_id' => null, + 'created_order_id' => null, + 'candidate_order' => null, + 'proposed_order' => null, + 'can_accept' => false, + 'can_deny' => false, + ]; + } + + private function decodeJsonField(?string $value): mixed + { + if ($value === null || $value === '') { + return null; + } + + $decoded = json_decode($value, true); + return json_last_error() === JSON_ERROR_NONE ? $decoded : null; + } + + private function itemSignatureParts(array $items): array + { + return self::itemSignaturePartsForAutomation($items); + } + + private function compactItems(array $items): array + { + return array_map(fn(array $item): array => [ + 'product_id' => (int)($item['product_id'] ?? 0), + 'product_name' => (string)($item['product']['name'] ?? $item['product_name'] ?? ''), + 'quantity' => (int)($item['quantity'] ?? 0), + 'price' => (int)($item['price'] ?? 0), + ], $items); + } + + private function itemsTotal(array $items): int + { + return array_reduce($items, fn(int $total, array $item): int => $total + ((int)($item['price'] ?? 0) * (int)($item['quantity'] ?? 0)), 0); + } + + private function productOverlap(array $usageItems, array $orderItems): float + { + $usageBag = $this->productBag($usageItems); + $orderBag = $this->productBag($orderItems); + $usageTotal = array_sum($usageBag); + if ($usageTotal <= 0) { + return 0.0; + } + + $overlap = 0; + foreach ($usageBag as $productId => $quantity) { + $overlap += min($quantity, $orderBag[$productId] ?? 0); + } + + return $overlap / $usageTotal; + } + + private function productBag(array $items): array + { + $bag = []; + foreach ($items as $item) { + $productId = (int)($item['product_id'] ?? 0); + if ($productId < 1) { + continue; + } + $bag[$productId] = ($bag[$productId] ?? 0) + max(1, (int)($item['quantity'] ?? 1)); + } + + return $bag; + } + + private function normalizeRegistration(string $registration): string + { + return self::normalizeRegistrationForAutomation($registration); + } + + private function isOpenAiEnabled(): bool + { + try { + $xlvask = new xlvask(); + if (!$xlvask->config->openai_integration_enabled->isTrue()) { + return false; + } + + $openai = new openai(); + return $openai->config->enabled->isTrue(); + } catch (Exception) { + return false; + } + } +} diff --git a/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php b/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php new file mode 100644 index 00000000..b300973c --- /dev/null +++ b/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php @@ -0,0 +1,145 @@ +query( + "CREATE TABLE IF NOT EXISTS `xlvask_automation_suggestions` ( + `id` INT NOT NULL AUTO_INCREMENT, + `usage_log_id` INT NOT NULL, + `wash_id` VARCHAR(128) NOT NULL, + `signature_hash` CHAR(64) NOT NULL, + `signature_json` LONGTEXT NULL, + `action` VARCHAR(32) NOT NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'suggested', + `confidence` DECIMAL(5,4) NOT NULL DEFAULT 0.0000, + `source` VARCHAR(32) NOT NULL DEFAULT 'deterministic', + `matched_order_id` INT NULL, + `created_order_id` INT NULL, + `proposed_order_json` LONGTEXT NULL, + `candidate_order_json` LONGTEXT NULL, + `reason` TEXT NULL, + `created_by` INT NULL, + `decided_by` INT NULL, + `decided_at` DATETIME NULL, + `executed_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_xlvask_automation_usage` (`usage_log_id`), + KEY `idx_xlvask_automation_wash` (`wash_id`), + KEY `idx_xlvask_automation_signature` (`signature_hash`), + KEY `idx_xlvask_automation_status` (`status`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + $db->query( + "CREATE TABLE IF NOT EXISTS `xlvask_automation_feedback` ( + `id` INT NOT NULL AUTO_INCREMENT, + `usage_log_id` INT NULL, + `wash_id` VARCHAR(128) NULL, + `signature_hash` CHAR(64) NOT NULL, + `signature_json` LONGTEXT NULL, + `action` VARCHAR(32) NOT NULL, + `decision` VARCHAR(32) NOT NULL, + `order_id` INT NULL, + `reason` TEXT NULL, + `created_by` INT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_xlvask_feedback_signature_action` (`signature_hash`, `action`), + KEY `idx_xlvask_feedback_usage` (`usage_log_id`), + KEY `idx_xlvask_feedback_decision` (`decision`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + $db->query( + "CREATE TABLE IF NOT EXISTS `xlvask_automation_openai_cache` ( + `id` INT NOT NULL AUTO_INCREMENT, + `cache_key` CHAR(64) NOT NULL, + `schema_name` VARCHAR(96) NOT NULL, + `input_json` LONGTEXT NOT NULL, + `result_json` LONGTEXT NOT NULL, + `hits` INT NOT NULL DEFAULT 0, + `last_hit_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`), + KEY `idx_xlvask_openai_cache_schema` (`schema_name`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + } + + private static function addColumnIfMissing(object $db, string $table, string $column, string $definition): void + { + if (!self::columnExists($db, $table, $column)) { + $db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}"); + } + } + + private static function tableExists(object $db, string $table): bool + { + $table = self::escapeIdentifier($table); + $result = $db->query("SHOW TABLES LIKE '{$table}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function columnExists(object $db, string $table, string $column): bool + { + $table = self::escapeIdentifier($table); + $column = self::escapeIdentifier($column); + $result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"); + + if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) { + return false; + } + + return (int)$result->num_rows > 0; + } + + private static function escapeIdentifier(string $value): string + { + return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value); + } +} diff --git a/services/nginx/app/composer.json b/services/nginx/app/composer.json index 0b54246c..22faa24e 100644 --- a/services/nginx/app/composer.json +++ b/services/nginx/app/composer.json @@ -5,7 +5,7 @@ "test:integration": "vendor/bin/pest --testsuite=Integration --colors=always", "test:api": [ "Composer\\Config::disableProcessTimeout", - "@php -r \"putenv('RUN_API_TESTS=1'); passthru('vendor/bin/pest --testsuite=Api --colors=always', $exitCode); exit($exitCode);\"" + "@php -r \"putenv('RUN_API_TESTS=1'); putenv('CONFIG_DB_TARGET=debug'); passthru('vendor/bin/pest --testsuite=Api --colors=always', $exitCode); exit($exitCode);\"" ], "test:api:edge": [ "Composer\\Config::disableProcessTimeout", @@ -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": { diff --git a/services/nginx/app/composer.lock b/services/nginx/app/composer.lock index a6697be9..e64c5ae9 100644 --- a/services/nginx/app/composer.lock +++ b/services/nginx/app/composer.lock @@ -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" } diff --git a/services/nginx/app/config.php b/services/nginx/app/config.php index be5988ad..d0c623bc 100644 --- a/services/nginx/app/config.php +++ b/services/nginx/app/config.php @@ -163,3 +163,14 @@ if (strtolower(trim((string)($_ENV['USE_ENV'] ?? getenv('USE_ENV') ?? ''))) === // Throw an error if the environment variables are not set throw new Exception('Environment variables are not set'); } + +require_once __DIR__ . '/classes/replication_secret_box.php'; +require_once __DIR__ . '/classes/replication_bootstrap_config.php'; +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()); +} diff --git a/services/nginx/app/cron/Cron.php b/services/nginx/app/cron/Cron.php index 407b72bb..5dd223e5 100644 --- a/services/nginx/app/cron/Cron.php +++ b/services/nginx/app/cron/Cron.php @@ -4,10 +4,16 @@ 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; @@ -29,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; @@ -63,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, @@ -123,6 +148,12 @@ $cron_tasks = [ 'next_run' => 0, 'function' => 'PreloadDepartmentWeatherResponsesCron', ], + 'WarmWorkfeedEmployeeNamesCron' => [ + 'interval' => 21600, // 6 hours + 'last_run' => 0, + 'next_run' => 0, + 'function' => 'WarmWorkfeedEmployeeNamesCron', + ], 'GoalsProgressAlertsCron' => [ 'interval' => 60, // check every minute 'last_run' => 0, @@ -135,8 +166,343 @@ $cron_tasks = [ 'next_run' => 0, 'function' => 'PruneSystemSessionActivityCron', ], + 'WarmInvoicePeriodManualFlagsCron' => [ + 'interval' => 300, // 5 minutes + 'last_run' => 0, + 'next_run' => 0, + 'function' => 'WarmInvoicePeriodManualFlagsCron', + ], + 'WarmInvoicePeriodAutomaticFlagsCron' => [ + 'interval' => 300, // 5 minutes + 'last_run' => 0, + 'next_run' => 0, + 'function' => 'WarmInvoicePeriodAutomaticFlagsCron', + ], ]; +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(); +} + +function WarmInvoicePeriodAutomaticFlagsCron(): void +{ + $service = new invoice_period_flag_service(); + $now = new DateTime(); + $previousMonth = new DateTime('first day of previous month'); + + $toWarm = []; + foreach ([$now, $previousMonth] as $date) { + $dateFrom = $date->format('Y-m-01'); + $dateTo = $date->format('Y-m-t'); + $toWarm[$dateFrom . '|' . $dateTo] = ['dateFrom' => $dateFrom, 'dateTo' => $dateTo]; + } + + try { + $queued = (new redis())->consume_invoice_period_warming_queue(); + foreach ($queued as $period) { + $key = $period['dateFrom'] . '|' . $period['dateTo']; + $toWarm[$key] = $period; + } + } catch (Throwable) { + } + + foreach ($toWarm as $period) { + $service->warmOrderItemRowsForPeriod($period['dateFrom'], $period['dateTo']); + $service->warmAutomaticFlagsForPeriod($period['dateFrom'], $period['dateTo']); + } +} + +function WarmWorkfeedEmployeeNamesCron(): void +{ + if (!defined('redis')) { + warn('WarmWorkfeedEmployeeNamesCron skipped: Redis is unavailable.'); + return; + } + + $start = microtime(true); + + $ttlRaw = getenv('WORKFEED_EMPLOYEE_NAME_CACHE_TTL'); + $ttl = max( + 60, + (int)( + $ttlRaw !== false && trim((string)$ttlRaw) !== '' + ? $ttlRaw + : 86400 + ) + ); + + try { + $employeesResponse = (new workfeed())->listEmployees(); + } catch (Throwable $e) { + warn('WarmWorkfeedEmployeeNamesCron failed to list employees: ' . $e->getMessage()); + return; + } + + $employees = normalizeWorkfeedEmployeeWarmupCollection($employeesResponse); + if ($employees === []) { + echo "[" . date('Y-m-d H:i:s') . "][CRON] WarmWorkfeedEmployeeNamesCron: no employees returned.\n"; + return; + } + + try { + $cache = new redis(); + } catch (Throwable $e) { + warn('WarmWorkfeedEmployeeNamesCron failed to initialize cache: ' . $e->getMessage()); + return; + } + + $cachedCount = 0; + $skippedCount = 0; + foreach ($employees as $employee) { + $identity = extractWorkfeedEmployeeWarmupIdentity($employee); + $employeeIds = extractWorkfeedEmployeeWarmupIds($employee); + $employeeName = $identity['name'] ?? null; + if ($employeeIds === [] || $employeeName === null) { + $skippedCount++; + continue; + } + + foreach ($employeeIds as $employeeId) { + $cache->cache_workfeed_employee_name($employeeId, $employeeName, $ttl); + $cachedCount++; + } + } + + $durationMs = (int)round((microtime(true) - $start) * 1000); + echo "[" . date('Y-m-d H:i:s') . "][CRON] WarmWorkfeedEmployeeNamesCron: cached " . $cachedCount + . " employees, skipped " . $skippedCount . " in " . $durationMs . "ms.\n"; +} + +/** + * @return array + */ +function normalizeWorkfeedEmployeeWarmupCollection(mixed $raw): array +{ + if (is_array($raw)) { + return array_values($raw); + } + + if ($raw instanceof Traversable) { + return array_values(iterator_to_array($raw, false)); + } + + if (!is_object($raw)) { + return []; + } + + $record = get_object_vars($raw); + foreach (['data', 'items', 'employees', 'results'] as $key) { + $nested = $record[$key] ?? null; + $normalized = normalizeWorkfeedEmployeeWarmupCollection($nested); + if ($normalized !== []) { + return $normalized; + } + } + + return [$raw]; +} + +/** + * @return array{id:?string,name:?string} + */ +function extractWorkfeedEmployeeWarmupIdentity(mixed $employee): array +{ + $employeeIds = extractWorkfeedEmployeeWarmupIds($employee); + + $record = is_object($employee) + ? get_object_vars($employee) + : (is_array($employee) ? $employee : []); + + if ($record === []) { + return [ + 'id' => null, + 'name' => null, + ]; + } + + $employeeId = $employeeIds[0] ?? null; + + $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', + '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', + ], $employeeId); + + return [ + 'id' => $employeeId, + 'name' => $employeeName, + ]; +} + +/** + * @return array + */ +function extractWorkfeedEmployeeWarmupIds(mixed $employee): array +{ + $record = is_object($employee) + ? get_object_vars($employee) + : (is_array($employee) ? $employee : []); + + if ($record === []) { + return []; + } + + $employeeIds = []; + foreach ([ + 'employeeID', + 'employeeId', + 'id', + 'uuid', + 'employee.id', + 'employee.employeeID', + 'employee.employeeId', + 'employee.uuid', + 'employeeUUID', + 'employee_uuid', + 'user.id', + 'userId', + ] as $path) { + $employeeId = normalizeWarmupTextValue(getWarmupRecordValueByPath($record, $path)); + if ($employeeId === null) { + continue; + } + + $employeeIds[$employeeId] = true; + } + + return array_keys($employeeIds); +} + +function getWarmupRecordValueByPath(array $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; +} + +function normalizeWarmupTextValue(mixed $value): ?string +{ + if (is_string($value) || is_numeric($value)) { + $normalized = trim((string)$value); + return $normalized !== '' ? $normalized : null; + } + + return null; +} + function checkUnfulfilledBookings(): void { // This is deactivated for now, as it is not wanted. @@ -493,7 +859,7 @@ function collectDynamicImageTaskGroupsForLane(array $laneRow): array /** * @param array> $taskRows - * @return array|null,current_step:int,only_current_step:bool,vehicle_type:int|null}> + * @return array|null,current_step:int,only_current_step:bool,vehicle_type:int|null}> */ function buildDynamicImageVariantsForTaskGroup(int $dynamicImageId, ?int $vehicleType, array $taskRows): array { @@ -515,7 +881,7 @@ function buildDynamicImageVariantsForTaskGroup(int $dynamicImageId, ?int $vehicl $buttons = parseDynamicImageButtons($row['buttons'] ?? null); if ($buttons !== []) { $buttonSets[] = $buttons; - $runningButtons = mergeUniqueIntValues($runningButtons, $buttons); + $runningButtons = mergeUniqueButtonValues($runningButtons, $buttons); $buttonSets[] = $runningButtons; } } @@ -554,7 +920,7 @@ function buildDynamicImageVariantsForTaskGroup(int $dynamicImageId, ?int $vehicl } /** - * @param array|null $buttons + * @param array|null $buttons */ function buildDynamicImageCacheKey(array $variant): string { @@ -575,7 +941,7 @@ function buildDynamicImageCacheKey(array $variant): string } /** - * @param array|null $buttons + * @param array|null $buttons */ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $currentStep, bool $onlyCurrentStep): ?string { @@ -621,7 +987,7 @@ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $cu /** * @param mixed $value - * @return array + * @return array */ function parseDynamicImageButtons(mixed $value): array { @@ -630,8 +996,7 @@ function parseDynamicImageButtons(mixed $value): array } try { - $normalized = department_selfserve_tasks_o::normalizeButtonsInput($value); - return array_values(array_map('intval', $normalized)); + return department_selfserve_tasks_o::normalizeButtonsInput($value); } catch (Throwable) { return []; } @@ -662,17 +1027,22 @@ function normalizeDynamicImageVehicleType(mixed $value): ?int } /** - * @param array $base - * @param array $append - * @return array + * @param array $base + * @param array $append + * @return array */ -function mergeUniqueIntValues(array $base, array $append): array +function mergeUniqueButtonValues(array $base, array $append): array { $result = $base; + $seen = []; + foreach ($result as $value) { + $seen[(is_int($value) ? 'int:' : 'string:') . (string)$value] = true; + } foreach ($append as $value) { - $intValue = (int)$value; - if (!in_array($intValue, $result, true)) { - $result[] = $intValue; + $key = (is_int($value) ? 'int:' : 'string:') . (string)$value; + if (!isset($seen[$key])) { + $seen[$key] = true; + $result[] = $value; } } return array_values($result); diff --git a/services/nginx/app/index.php b/services/nginx/app/index.php index 71487479..4e78baf9 100644 --- a/services/nginx/app/index.php +++ b/services/nginx/app/index.php @@ -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, 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, 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); @@ -167,14 +157,16 @@ 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); @@ -195,7 +187,49 @@ 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'] ?? '/', + php_sapi_name() === 'cli' || isset($_GET['internalCronCall']) +)) { + $freezeState = application_write_freeze::state(); + if (php_sapi_name() === 'cli') { + fwrite(STDERR, 'Application writes are frozen: ' . (string)($freezeState['reason'] ?? 'replication promotion') . PHP_EOL); + exit(75); + } + + $response->error([ + 'message' => 'Application writes are temporarily frozen.', + 'reason' => $freezeState['reason'] ?? null, + 'expires_at' => $freezeState['expires_at'] ?? null, + ], 503); +} // If the program was called from the command line, run the cli script if (php_sapi_name() === 'cli' || isset($_GET['internalCronCall'])) { diff --git a/services/nginx/app/interfaces/redis_i.php b/services/nginx/app/interfaces/redis_i.php index fb5da8a6..152bba20 100644 --- a/services/nginx/app/interfaces/redis_i.php +++ b/services/nginx/app/interfaces/redis_i.php @@ -290,6 +290,98 @@ interface redis_i */ public function clear_auth_session(string $token): self; + /** + * Cache invoice period manual flags payload + * @param array $flags + * @return self + */ + public function cache_invoice_period_manual_flags(array $flags): self; + + /** + * Get cached invoice period manual flags payload + * @return array|null + */ + public function get_invoice_period_manual_flags(): array|null; + + /** + * Clear cached invoice period manual flags payload + * @return self + */ + public function clear_invoice_period_manual_flags(): self; + + /** + * Cache invoice period automatic flags payload for a date range + * @param string $dateFrom + * @param string $dateTo + * @param array $flags + * @return self + */ + public function cache_invoice_period_automatic_flags(string $dateFrom, string $dateTo, array $flags): self; + + /** + * Get cached invoice period automatic flags payload for a date range + * @param string $dateFrom + * @param string $dateTo + * @return array|null + */ + public function get_invoice_period_automatic_flags(string $dateFrom, string $dateTo): array|null; + + /** + * Clear cached invoice period automatic flags payload for a date range + * @param string $dateFrom + * @param string $dateTo + * @return self + */ + public function clear_invoice_period_automatic_flags(string $dateFrom, string $dateTo): self; + + /** + * Cache invoice period order item rows for a date range + * @param string $dateFrom + * @param string $dateTo + * @param array $rows + * @return self + */ + public function cache_invoice_period_order_item_rows(string $dateFrom, string $dateTo, array $rows): self; + + /** + * Get cached invoice period order item rows for a date range + * @param string $dateFrom + * @param string $dateTo + * @return array|null + */ + public function get_invoice_period_order_item_rows(string $dateFrom, string $dateTo): array|null; + + /** + * Clear cached invoice period order item rows for a date range + * @param string $dateFrom + * @param string $dateTo + * @return self + */ + public function clear_invoice_period_order_item_rows(string $dateFrom, string $dateTo): self; + + /** + * Cache Workfeed employee display name by employee id + * @param string $employeeId + * @param string $employeeName + * @param int $ttl + * @return self + */ + public function cache_workfeed_employee_name(string $employeeId, string $employeeName, int $ttl = 86400): self; + + /** + * Get cached Workfeed employee display name by employee id + * @param string $employeeId + * @return string|null + */ + public function get_workfeed_employee_name(string $employeeId): string|null; + + /** + * Clear cached Workfeed employee display name by employee id + * @param string $employeeId + * @return self + */ + public function clear_workfeed_employee_name(string $employeeId): self; + /** * Cache a permission evaluation * @param string $cache_key @@ -312,4 +404,18 @@ interface redis_i * @return self */ public function clear_permission(string $cache_key): self; + + /** + * Enqueue a period for automatic flags warming. + * @param string $dateFrom + * @param string $dateTo + * @return self + */ + public function enqueue_invoice_period_warming(string $dateFrom, string $dateTo): self; + + /** + * Consume all queued warming periods and clear the queue. + * @return array + */ + public function consume_invoice_period_warming_queue(): array; } \ No newline at end of file diff --git a/services/nginx/app/modules/attachments/helpers/attachment_content.php b/services/nginx/app/modules/attachments/helpers/attachment_content.php index b395fd8d..cbf410f2 100644 --- a/services/nginx/app/modules/attachments/helpers/attachment_content.php +++ b/services/nginx/app/modules/attachments/helpers/attachment_content.php @@ -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; } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/coolify/config/coolify_enabled_c.php b/services/nginx/app/modules/coolify/config/coolify_enabled_c.php new file mode 100644 index 00000000..859c61ee --- /dev/null +++ b/services/nginx/app/modules/coolify/config/coolify_enabled_c.php @@ -0,0 +1,25 @@ +setupConfigVariable( + 'Coolify', + 'enabled', + 'bool', + false, + null, + 'Enable Coolify-managed replicated infrastructure targets.', + 'true', + false, + 'false' + ); + } +} diff --git a/services/nginx/app/modules/coolify/config/coolify_hetzner_cloud_api_token_c.php b/services/nginx/app/modules/coolify/config/coolify_hetzner_cloud_api_token_c.php new file mode 100644 index 00000000..bb502c55 --- /dev/null +++ b/services/nginx/app/modules/coolify/config/coolify_hetzner_cloud_api_token_c.php @@ -0,0 +1,38 @@ +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); + } +} diff --git a/services/nginx/app/modules/coolify/config/coolify_hetzner_load_balancer_id_c.php b/services/nginx/app/modules/coolify/config/coolify_hetzner_load_balancer_id_c.php new file mode 100644 index 00000000..7252a38e --- /dev/null +++ b/services/nginx/app/modules/coolify/config/coolify_hetzner_load_balancer_id_c.php @@ -0,0 +1,25 @@ +setupConfigVariable( + 'Coolify', + 'hetzner_load_balancer_id', + 'string', + false, + null, + 'Hetzner Cloud Load Balancer ID used as the public Coolify gateway.', + '1234567', + false, + '' + ); + } +} diff --git a/services/nginx/app/modules/coolify/config/coolify_lb_automation_enabled_c.php b/services/nginx/app/modules/coolify/config/coolify_lb_automation_enabled_c.php new file mode 100644 index 00000000..ae273265 --- /dev/null +++ b/services/nginx/app/modules/coolify/config/coolify_lb_automation_enabled_c.php @@ -0,0 +1,25 @@ +setupConfigVariable( + 'Coolify', + 'lb_automation_enabled', + 'bool', + false, + null, + 'Enable automated Hetzner Load Balancer reconciliation for the Coolify public gateway.', + 'false', + false, + 'false' + ); + } +} diff --git a/services/nginx/app/modules/coolify/config/coolify_lb_automation_mode_c.php b/services/nginx/app/modules/coolify/config/coolify_lb_automation_mode_c.php new file mode 100644 index 00000000..b3183947 --- /dev/null +++ b/services/nginx/app/modules/coolify/config/coolify_lb_automation_mode_c.php @@ -0,0 +1,25 @@ +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' + ); + } +} diff --git a/services/nginx/app/modules/coolify/config/coolify_public_gateway_host_c.php b/services/nginx/app/modules/coolify/config/coolify_public_gateway_host_c.php new file mode 100644 index 00000000..e62cefd7 --- /dev/null +++ b/services/nginx/app/modules/coolify/config/coolify_public_gateway_host_c.php @@ -0,0 +1,25 @@ +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' + ); + } +} diff --git a/services/nginx/app/modules/coolify/coolify_c.php b/services/nginx/app/modules/coolify/coolify_c.php new file mode 100644 index 00000000..68df3b39 --- /dev/null +++ b/services/nginx/app/modules/coolify/coolify_c.php @@ -0,0 +1,63 @@ +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()); + } +} diff --git a/services/nginx/app/modules/dynamicimages/images/machine_1.php b/services/nginx/app/modules/dynamicimages/images/machine_1.php index 434c06a0..b02ed759 100644 --- a/services/nginx/app/modules/dynamicimages/images/machine_1.php +++ b/services/nginx/app/modules/dynamicimages/images/machine_1.php @@ -17,6 +17,9 @@ class machine_1 extends dynamicimages_image const IMAGE_BUTTON_HIGHLIGHTED_GREY = 'machine_1_button_highlighted_grey.png'; const IMAGE_BUTTON_HIGHLIGHTED_COMPLETED = 'machine_1_button_highlighted_completed_grey.png'; 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 @@ -71,11 +74,9 @@ class machine_1 extends dynamicimages_image $this->drawAsset($this->getAsset(self::IMAGE_PANEL_BACKGROUND), 0, 0); $this->drawProgramWheel(); $this->drawThumb(); - $this->drawStepThumb(); - $this->drawHighlightedResetButton(); - $this->drawHighlightedButtons(); + $deferredStartButtons = $this->drawHighlightedButtonSequence(); $this->drawAsset($this->getAsset(self::IMAGE_POWER_BUTTON), 0, 0); - $this->drawHighlightedStartButton(); + $this->drawDeferredHighlightedButtons($deferredStartButtons); $this->drawCropOutLine(true); } @@ -174,11 +175,211 @@ class machine_1 extends dynamicimages_image return self::IMAGE_BUTTON_HIGHLIGHTED_GREY; } + private function isHighlightedButton(int|string $button): bool + { + foreach ($this->highlighted_buttons as $highlightedButton) { + if (is_int($button)) { + if (is_numeric($highlightedButton) && (int)$highlightedButton === $button) { + return true; + } + continue; + } + + if (is_string($highlightedButton) && strtolower(trim($highlightedButton)) === $button) { + return true; + } + } + + return false; + } + + private function normalizeHighlightedButtonToken(mixed $button): int|string|null + { + if (is_string($button)) { + $trimmed = trim($button); + $specialButton = strtolower($trimmed); + if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START || $specialButton === self::BUTTON_PROGRAM_PICKER) { + return $specialButton; + } + + if ($trimmed !== '' && ctype_digit($trimmed)) { + $button = (int)$trimmed; + } + } + + if (is_int($button)) { + return $button >= 0 && $button < ($this->button_rows * $this->button_columns) ? $button : null; + } + + if (is_numeric($button) && (int)$button == $button) { + $button = (int)$button; + return $button >= 0 && $button < ($this->button_rows * $this->button_columns) ? $button : null; + } + + return null; + } + + private function getOrderedHighlightedButtonTokens(): array + { + $tokens = []; + foreach ($this->highlighted_buttons as $button) { + $token = $this->normalizeHighlightedButtonToken($button); + if ($token !== null) { + $tokens[] = $token; + } + } + + return $tokens; + } + + private function getRegularButtonCoordinates(int $buttonIndex): ?array + { + if ($buttonIndex < 0 || $buttonIndex >= ($this->button_rows * $this->button_columns)) { + return null; + } + + $row = intdiv($buttonIndex, $this->button_columns); + $col = $buttonIndex % $this->button_columns; + $x = 2815 + ($col * ($this->button_highlight_size + $this->button_columns_spacing)); + $y = 1265 + ($row * ($this->button_highlight_size + $this->button_rows_spacing)); + if ($row === $this->button_rows - 1) { + $y += $this->button_last_row_spacing_buffer; + } + + return [ + 'x' => $x, + 'y' => $y, + 'size' => $this->button_highlight_size, + ]; + } + + private function getHighlightedButtonCoordinates(int|string $button): ?array + { + if ($button === self::BUTTON_PROGRAM_PICKER) { + return $this->getProgramPickerStepCoordinates(); + } + + if ($button === self::BUTTON_RESET) { + return [ + 'x' => 1736, + 'y' => 599, + 'size' => $this->reset_button_highlight_size, + ]; + } + + if ($button === self::BUTTON_START) { + return [ + 'x' => 4965, + 'y' => 1980, + 'size' => $this->start_button_highlight_size, + ]; + } + + 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 + */ + private function buildHighlightedButtonDraw(int|string $button): ?array + { + $coordinates = $this->getHighlightedButtonCoordinates($button); + if ($coordinates === null) { + return null; + } + + $tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName())); + $tmp->resize($coordinates['size'], $coordinates['size']); + $tmp = $this->drawStepCounterOnButton($tmp); + if ($this->only_generate_current_step && $this->button_counter != $this->current_step + 1) { + if (method_exists($tmp, 'clearMemoryImage')) { + $tmp->clearMemoryImage(); + } + return null; + } + + return [ + 'asset' => $tmp, + 'x' => $coordinates['x'], + 'y' => $coordinates['y'], + ]; + } + + /** + * @throws \Exception + */ + private function drawHighlightedButtonDraw(array $draw): void + { + $tmp = $draw['asset']; + $this->drawAsset($tmp, (int)$draw['x'], (int)$draw['y']); + if (method_exists($tmp, 'clearMemoryImage')) { + $tmp->clearMemoryImage(); + } + } + + /** + * @return array + * @throws \Exception + */ + public function drawHighlightedButtonSequence(): array + { + $deferredStartButtons = []; + foreach ($this->getOrderedHighlightedButtonTokens() as $button) { + $draw = $this->buildHighlightedButtonDraw($button); + if ($draw === null) { + continue; + } + + if ($button === self::BUTTON_START) { + $deferredStartButtons[] = $draw; + continue; + } + + $this->drawHighlightedButtonDraw($draw); + } + + return $deferredStartButtons; + } + + /** + * @param array $deferredStartButtons + * @throws \Exception + */ + public function drawDeferredHighlightedButtons(array $deferredStartButtons): void + { + foreach ($deferredStartButtons as $draw) { + $this->drawHighlightedButtonDraw($draw); + } + } + /** * @throws \Exception */ public function drawHighlightedStartButton(): void { + if (!$this->isHighlightedButton(self::BUTTON_START)) { + return; + } + $x = 4965; // X position for the start button $y = 1980; // Y position for the start button $tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName())); @@ -200,6 +401,10 @@ class machine_1 extends dynamicimages_image */ public function drawHighlightedResetButton(): void { + if (!$this->isHighlightedButton(self::BUTTON_RESET)) { + return; + } + $x = 1736; // X position for the reset button $y = 599; // Y position for the reset button $tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName())); @@ -404,7 +609,7 @@ class machine_1 extends dynamicimages_image for ($col = 0; $col < $this->button_columns; $col++) { // If the current button position is not in the highlighted buttons array, skip it $buttonIndex = $row * $this->button_columns + $col; - if (!in_array($buttonIndex, $this->highlighted_buttons, true)) { + if (!$this->isHighlightedButton($buttonIndex)) { continue; } // Calculate the position for the current button @@ -517,16 +722,12 @@ class machine_1 extends dynamicimages_image $rad = deg2rad($angle); $stepX = $centerX + (int)round($radius * cos($rad)) - (int)floor($stepSize / 2); $stepY = $centerY + (int)round($radius * sin($rad)) - (int)floor($stepSize / 2); - $tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName())); + $tmp = new dynamicimages_asset($this->getAssetPath(self::IMAGE_BUTTON_HIGHLIGHTED_BLUE)); $tmp->resize($stepSize, $stepSize); - $tmp = $this->drawStepCounterOnButton($tmp); - if ($this->only_generate_current_step && $this->button_counter != $this->current_step +1) { - return; - } $this->drawAsset($tmp, $stepX, $stepY); // Free memory used by temporary asset image, if any if (method_exists($tmp, 'clearMemoryImage')) { $tmp->clearMemoryImage(); } } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/economic/config/economic_default_department_id_c.php b/services/nginx/app/modules/economic/config/economic_default_department_id_c.php new file mode 100644 index 00000000..994537e4 --- /dev/null +++ b/services/nginx/app/modules/economic/config/economic_default_department_id_c.php @@ -0,0 +1,28 @@ +invoice_layout = new economic_invoice_layout_c(); @@ -41,6 +45,7 @@ class economic_c $this->admin_fee_monthly = new economic_admin_fee_monthly_c(); $this->admin_fee_order = new economic_admin_fee_order_c(); $this->fee_product_id = new economic_fee_product_id_c(); + $this->default_department_id = new economic_default_department_id_c(); $this->transaction_draft_customer_number = new economic_transaction_draft_customer_number_c(); } } diff --git a/services/nginx/app/modules/economic/economic_m.php b/services/nginx/app/modules/economic/economic_m.php index 81fe86ab..5a8594c7 100644 --- a/services/nginx/app/modules/economic/economic_m.php +++ b/services/nginx/app/modules/economic/economic_m.php @@ -92,7 +92,8 @@ class economic_m CURLOPT_RETURNTRANSFER => true, CURLOPT_ENCODING => '', CURLOPT_MAXREDIRS => 10, - CURLOPT_TIMEOUT => 0, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_TIMEOUT => 30, CURLOPT_FOLLOWLOCATION => true, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, CURLOPT_CUSTOMREQUEST => $method, diff --git a/services/nginx/app/modules/edgegateway/classes/edge_gateway_department_workspace_service.php b/services/nginx/app/modules/edgegateway/classes/edge_gateway_department_workspace_service.php index 3e3c3266..fe05f23b 100644 --- a/services/nginx/app/modules/edgegateway/classes/edge_gateway_department_workspace_service.php +++ b/services/nginx/app/modules/edgegateway/classes/edge_gateway_department_workspace_service.php @@ -264,6 +264,7 @@ class edge_gateway_department_workspace_service 'relay_machine_cleaner_id' => $lane->relay_machine_cleaner_id->value() === null ? null : (string)$lane->relay_machine_cleaner_id->value(), 'dynamic_image_id' => $lane->dynamic_image_id->value() === null ? null : (int)$lane->dynamic_image_id->value(), 'machine_type_id' => $lane->machine_type_id->value() === null ? null : (int)$lane->machine_type_id->value(), + 'selfserve_enabled' => $lane->isSelfServeEnabled(), 'status' => $laneStatus, 'self_serve_products' => $lane->getSelfServeLaneProducts(), 'relay_slots' => $relaySlots, @@ -385,7 +386,10 @@ class edge_gateway_department_workspace_service } catch (\Throwable) { } - $readyLanes = array_values(array_filter($lanes, static function (array $lane): bool { + $enabledLanes = array_values(array_filter($lanes, static function (array $lane): bool { + return ($lane['selfserve_enabled'] ?? true) !== false; + })); + $readyLanes = array_values(array_filter($enabledLanes, static function (array $lane): bool { return (string)($lane['binding_coverage']['state'] ?? 'UNKNOWN') === 'READY'; })); @@ -404,12 +408,13 @@ class edge_gateway_department_workspace_service return [ 'enabled' => $enabled, 'lane_count' => count($lanes), + 'enabled_lanes' => count($enabledLanes), 'ready_lanes' => count($readyLanes), 'configured_task_count' => count($taskRows), 'configured_product_count' => count($productIds), 'readiness_state' => !$enabled ? 'DISABLED' - : (count($lanes) === 0 ? 'UNCONFIGURED' : (count($readyLanes) === count($lanes) ? 'READY' : 'PARTIAL')), + : (count($enabledLanes) === 0 ? 'UNCONFIGURED' : (count($readyLanes) === count($enabledLanes) ? 'READY' : 'PARTIAL')), 'links' => [ 'studio' => '/admin/' . (int)$department->id . '/modules/self-serve/studio', 'legacy' => '/superuser/selfserve', @@ -594,7 +599,7 @@ class edge_gateway_department_workspace_service } } - if (($selfServe['enabled'] ?? false) && (int)($selfServe['ready_lanes'] ?? 0) < (int)($selfServe['lane_count'] ?? 0)) { + if (($selfServe['enabled'] ?? false) && (int)($selfServe['ready_lanes'] ?? 0) < (int)($selfServe['enabled_lanes'] ?? 0)) { $issues[] = [ 'severity' => 'warning', 'code' => 'SELFSERVE_PARTIAL_READY', diff --git a/services/nginx/app/modules/edgegateway/classes/edge_gateway_manager.php b/services/nginx/app/modules/edgegateway/classes/edge_gateway_manager.php index 2a019b58..48163a9b 100644 --- a/services/nginx/app/modules/edgegateway/classes/edge_gateway_manager.php +++ b/services/nginx/app/modules/edgegateway/classes/edge_gateway_manager.php @@ -3,6 +3,7 @@ namespace classes; use Exception; +use objects\department_relays_o; use objects\departments_o; use objects\department_variables_o; use objects\edge_gateway_audit_logs_o; @@ -76,12 +77,24 @@ class edge_gateway_manager public const CREDENTIAL_FRESH_AFTER_SECONDS = 2592000; /** @var array>|null */ private ?array $shellyRelayOptionsCache = null; + /** @var array> */ + private static array $relayActionContextStack = []; public function __construct() { edge_gateway_schema_bootstrap::ensureTables(); } + public static function withRelayActionContext(array $context, callable $callback): mixed + { + self::$relayActionContextStack[] = $context; + try { + return $callback(); + } finally { + array_pop(self::$relayActionContextStack); + } + } + private static function configuredDefaultReleaseChannel(): string { try { @@ -157,7 +170,7 @@ class edge_gateway_manager $lastSeenAt = isset($presence['last_seen_at']) ? (string)$presence['last_seen_at'] : null; $ageSeconds = self::heartbeatAgeSeconds($lastSeenAt); $connected = self::isBrokerPresenceConnected($presence); - $configuredBrokerUrl = trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: '')); + $configuredBrokerUrl = $this->configuredPublicBrokerUrl(); $derivedWarning = null; if ($configuredBrokerUrl === '') { @@ -177,6 +190,7 @@ class edge_gateway_manager 'broker_url' => $brokerUrl, 'ws_url' => $wsUrl, 'public_broker_url_configured' => $configuredBrokerUrl !== '', + 'broker_auth_mode' => $this->configuredBrokerAuthMode(), 'derived_warning' => $derivedWarning, 'broker_presence' => [ 'connected' => !empty($presence['connected']), @@ -335,24 +349,79 @@ class edge_gateway_manager { $gateway = $this->authenticateGateway($gatewayId, $plainToken); $existingMetadata = (array)($gateway->metadata_json->value() ?? []); + $payloadMetadata = (array)($payload['metadata'] ?? []); + $metadata = $this->mergeHeartbeatBrokerPresence($gatewayId, $existingMetadata, $payloadMetadata); $gateway->status->set((string)($payload['status'] ?? self::STATUS_ONLINE)); $gateway->hostname->set($payload['hostname'] ?? $gateway->hostname->value()); $gateway->installed_version->set($payload['installed_version'] ?? $gateway->installed_version->value()); $gateway->target_version->set($payload['target_version'] ?? $gateway->target_version->value()); $gateway->last_heartbeat_at->set($this->now()); $gateway->last_seen_ip->set($this->remoteIp()); - $gateway->metadata_json->set(array_merge($existingMetadata, (array)($payload['metadata'] ?? []))); + $gateway->metadata_json->set($metadata); if (isset($payload['inventory']) && is_array($payload['inventory'])) { $this->syncDeviceInventory($gatewayId, $payload['inventory']); } $gatewayPayload = $this->getGateway($gatewayId); + $gatewayPayload['broker_url'] = $this->buildBrokerPublicUrl(); edge_gateway_view_cache::syncGateway($gatewayPayload); return $gatewayPayload; } + private function mergeHeartbeatBrokerPresence(int $gatewayId, array $existingMetadata, array $payloadMetadata): array + { + $metadata = array_merge($existingMetadata, $payloadMetadata); + if (!array_key_exists('broker_connected', $payloadMetadata)) { + return $metadata; + } + + $connected = (bool)$payloadMetadata['broker_connected']; + $existingPresence = isset($existingMetadata['broker_presence']) && is_array($existingMetadata['broker_presence']) + ? (array)$existingMetadata['broker_presence'] + : []; + $presenceMetadata = isset($existingPresence['metadata']) && is_array($existingPresence['metadata']) + ? (array)$existingPresence['metadata'] + : []; + $now = $this->now(); + $disconnectReason = isset($payloadMetadata['broker_disconnect_reason']) + ? trim((string)$payloadMetadata['broker_disconnect_reason']) + : ''; + $lastError = isset($payloadMetadata['broker_last_error']) + ? trim((string)$payloadMetadata['broker_last_error']) + : ''; + + $presence = [ + 'gateway_id' => $gatewayId, + 'connected' => $connected, + 'connection_id' => isset($existingPresence['connection_id']) && trim((string)$existingPresence['connection_id']) !== '' + ? (string)$existingPresence['connection_id'] + : null, + 'last_seen_at' => $now, + 'disconnect_reason' => $connected ? null : ($disconnectReason !== '' ? $disconnectReason : null), + 'last_error' => $connected ? null : ($lastError !== '' ? $lastError : ($disconnectReason !== '' ? $disconnectReason : null)), + 'metadata' => array_merge($presenceMetadata, array_filter([ + 'agent_instance_id' => $payloadMetadata['agent_instance_id'] ?? null, + 'broker_url' => $payloadMetadata['broker_url'] ?? null, + ], static fn(mixed $value): bool => $value !== null && $value !== '')), + ]; + + $metadata['broker_presence'] = $presence; + $metadata['broker_connected'] = $connected; + if ($connected) { + $metadata['broker_connected_at'] = $metadata['broker_connected_at'] ?? $now; + $metadata['broker_last_error'] = null; + } else { + $metadata['broker_disconnected_at'] = $now; + $metadata['broker_last_error'] = $lastError !== '' ? $lastError : ($disconnectReason !== '' ? $disconnectReason : null); + } + + $this->writeBrokerPresence($gatewayId, $presence); + + return $metadata; + } + /** * @return array> * @throws Exception @@ -924,17 +993,17 @@ class edge_gateway_manager /** * @throws Exception */ - public function dispatchRelayStatus(int $departmentId, string $logicalRelayId): array + public function dispatchRelayStatus(int $departmentId, string $logicalRelayId, array $actionContext = []): array { - return $this->dispatchRelayStatusWithOptions($departmentId, $logicalRelayId); + return $this->dispatchRelayStatusWithOptions($departmentId, $logicalRelayId, false, $actionContext); } /** * @throws Exception */ - public function dispatchRelayStatusLocalOnly(int $departmentId, string $logicalRelayId): array + public function dispatchRelayStatusLocalOnly(int $departmentId, string $logicalRelayId, array $actionContext = []): array { - return $this->dispatchRelayStatusWithOptions($departmentId, $logicalRelayId, true); + return $this->dispatchRelayStatusWithOptions($departmentId, $logicalRelayId, true, $actionContext); } /** @@ -943,7 +1012,8 @@ class edge_gateway_manager private function dispatchRelayStatusWithOptions( int $departmentId, string $logicalRelayId, - bool $requireFastLocalPath = false + bool $requireFastLocalPath = false, + array $actionContext = [] ): array { $binding = $this->resolveRelayBinding($departmentId, $logicalRelayId); @@ -952,9 +1022,10 @@ class edge_gateway_manager if ($requireFastLocalPath) { $resolution = $this->forceLocalRelayExecutionPlan($resolution); } + $actionContext = $this->normalizeRelayActionContext($actionContext); if (($resolution['execution_path'] ?? 'local') === 'cloud') { - return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, null, $binding, $resolution); + return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, null, $binding, $resolution, null, $actionContext); } $statusRequest = [ @@ -969,23 +1040,33 @@ class edge_gateway_manager $statusRequest['device_generation'] = $deviceGeneration; } - $job = $this->createCommandJob((int)$gateway->id, 'GET_RELAY_STATUS', $statusRequest, null, [ + $job = $this->createCommandJob((int)$gateway->id, 'GET_RELAY_STATUS', $statusRequest, $this->resolveRelayRequestedBy($actionContext), [ 'preferred_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API), 'fallback_reason' => $resolution['reason'] ?? null, 'require_fast_path' => $requireFastLocalPath, ]); + $dispatchLog = [ + 'action' => 'STATUS', + 'handler' => 'local', + 'relay_id' => $logicalRelayId, + 'signal' => $this->buildRelayCommandSignal($job, $statusRequest), + 'action_context' => $actionContext, + ]; try { $result = $this->dispatchGatewayCommand($gateway, $job); - return $this->finalizeRelayDispatch($binding, $resolution, $result); + return $this->finalizeRelayDispatch($binding, $resolution, $result, $dispatchLog); } catch (Exception $exception) { + $this->appendRelayDispatchLog($binding, $resolution, false, $dispatchLog, [], $exception); return $this->handleRelayDispatchFailure( $departmentId, $logicalRelayId, $binding, $resolution, null, - $exception + $exception, + null, + $actionContext ); } } @@ -993,9 +1074,9 @@ class edge_gateway_manager /** * @throws Exception */ - public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on): array + public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on, array $actionContext = []): array { - return $this->dispatchRelaySwitchWithOptions($departmentId, $logicalRelayId, $on); + return $this->dispatchRelaySwitchWithOptions($departmentId, $logicalRelayId, $on, false, null, $actionContext); } /** @@ -1005,23 +1086,25 @@ class edge_gateway_manager int $departmentId, string $logicalRelayId, bool $on, - ?int $toggleAfterSeconds + ?int $toggleAfterSeconds, + array $actionContext = [] ): array { return $this->dispatchRelaySwitchWithOptions( $departmentId, $logicalRelayId, $on, false, - $this->normalizeRelayToggleAfter($toggleAfterSeconds) + $this->normalizeRelayToggleAfter($toggleAfterSeconds), + $actionContext ); } /** * @throws Exception */ - public function dispatchRelaySwitchLocalOnly(int $departmentId, string $logicalRelayId, bool $on): array + public function dispatchRelaySwitchLocalOnly(int $departmentId, string $logicalRelayId, bool $on, array $actionContext = []): array { - return $this->dispatchRelaySwitchWithOptions($departmentId, $logicalRelayId, $on, true); + return $this->dispatchRelaySwitchWithOptions($departmentId, $logicalRelayId, $on, true, null, $actionContext); } /** @@ -1031,14 +1114,16 @@ class edge_gateway_manager int $departmentId, string $logicalRelayId, bool $on, - ?int $toggleAfterSeconds + ?int $toggleAfterSeconds, + array $actionContext = [] ): array { return $this->dispatchRelaySwitchWithOptions( $departmentId, $logicalRelayId, $on, true, - $this->normalizeRelayToggleAfter($toggleAfterSeconds) + $this->normalizeRelayToggleAfter($toggleAfterSeconds), + $actionContext ); } @@ -1050,7 +1135,8 @@ class edge_gateway_manager string $logicalRelayId, bool $on, bool $requireFastLocalPath = false, - ?int $toggleAfterSeconds = null + ?int $toggleAfterSeconds = null, + array $actionContext = [] ): array { $binding = $this->resolveRelayBinding($departmentId, $logicalRelayId); @@ -1059,9 +1145,10 @@ class edge_gateway_manager if ($requireFastLocalPath) { $resolution = $this->forceLocalRelayExecutionPlan($resolution); } + $actionContext = $this->normalizeRelayActionContext($actionContext); if (($resolution['execution_path'] ?? 'local') === 'cloud') { - return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, $on, $binding, $resolution, $toggleAfterSeconds); + return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, $on, $binding, $resolution, $toggleAfterSeconds, $actionContext); } $request = [ @@ -1081,16 +1168,26 @@ class edge_gateway_manager $request['toggle_after'] = $toggleAfterSeconds; } - $job = $this->createCommandJob((int)$gateway->id, 'SET_RELAY_STATE', $request, null, [ + $job = $this->createCommandJob((int)$gateway->id, 'SET_RELAY_STATE', $request, $this->resolveRelayRequestedBy($actionContext), [ 'preferred_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API), 'fallback_reason' => $resolution['reason'] ?? null, 'require_fast_path' => $requireFastLocalPath, ]); + $dispatchLog = [ + 'action' => 'SWITCH', + 'handler' => 'local', + 'relay_id' => $logicalRelayId, + 'target_on' => $on, + 'toggle_after_seconds' => $toggleAfterSeconds, + 'signal' => $this->buildRelayCommandSignal($job, $request), + 'action_context' => $actionContext, + ]; try { $result = $this->dispatchGatewayCommand($gateway, $job); - return $this->finalizeRelayDispatch($binding, $resolution, $result); + return $this->finalizeRelayDispatch($binding, $resolution, $result, $dispatchLog); } catch (Exception $exception) { + $this->appendRelayDispatchLog($binding, $resolution, false, $dispatchLog, [], $exception); return $this->handleRelayDispatchFailure( $departmentId, $logicalRelayId, @@ -1098,7 +1195,8 @@ class edge_gateway_manager $resolution, $on, $exception, - $toggleAfterSeconds + $toggleAfterSeconds, + $actionContext ); } } @@ -1757,6 +1855,7 @@ BASH; 12 ); + $relayLogs = []; $timeline = []; foreach ($auditLogs as $auditLog) { $timeline[] = [ @@ -1768,8 +1867,12 @@ BASH; ]; } foreach ($liveLogs as $logEntry) { + $type = strtolower(trim((string)($logEntry['stream'] ?? ''))) === 'relay' ? 'relay' : 'log'; + if ($type === 'relay') { + $relayLogs[] = $logEntry; + } $timeline[] = [ - 'type' => 'log', + 'type' => $type, 'level' => (string)($logEntry['level'] ?? 'INFO'), 'message' => (string)($logEntry['message'] ?? ''), 'created_at' => (string)($logEntry['created_at'] ?? ''), @@ -1807,6 +1910,7 @@ BASH; 'timeline' => array_slice($timeline, 0, max(20, $limit)), 'audit_logs' => $auditLogs, 'log_entries' => $liveLogs, + 'relay_logs' => $relayLogs, 'shell_sessions' => $shellSessions, ]; } @@ -2130,6 +2234,65 @@ BASH; return (new edge_gateway_log_entries_o())->select($logEntryId)->asArray(); } + /** + * @throws Exception + */ + public function appendRelayTransportLog( + int $departmentId, + string $endpoint, + array $payload, + array|object|null $response, + string $handler = 'cloud', + ?string $errorMessage = null, + array $actionContext = [] + ): ?array { + try { + $gateway = $this->getPrimaryGatewayForDepartment($departmentId, false); + } catch (Exception) { + return null; + } + + $relayId = $this->inferRelayIdFromTransportPayload($payload); + $success = $errorMessage === null || trim($errorMessage) === ''; + $targetOn = array_key_exists('on', $payload) ? (bool)$payload['on'] : null; + $handler = strtolower(trim($handler)) === 'local' ? 'local' : 'cloud'; + + return $this->appendRelayDispatchLog( + [ + 'id' => null, + 'gateway_id' => (int)$gateway->id, + 'department_id' => $departmentId, + 'relay_id' => $relayId, + 'device_id' => $payload['deviceId'] ?? $payload['device_id'] ?? null, + 'channel' => $payload['channel'] ?? null, + ], + [ + 'execution_path' => $handler, + 'delivery_channel' => $handler === 'cloud' ? self::DELIVERY_CHANNEL_CLOUD : self::DELIVERY_CHANNEL_API, + 'reason' => 'direct_transport', + ], + $success, + [ + 'action' => $this->inferRelayActionFromEndpoint($endpoint), + 'handler' => $handler, + 'relay_id' => $relayId, + 'target_on' => $targetOn, + 'toggle_after_seconds' => $this->normalizeRelayToggleAfter( + isset($payload['toggle_after']) || isset($payload['toggleAfter']) || isset($payload['timer']) + ? (int)($payload['toggle_after'] ?? $payload['toggleAfter'] ?? $payload['timer']) + : null + ), + 'signal' => [ + 'endpoint' => $endpoint, + 'request' => $payload, + ], + 'action_context' => $this->normalizeRelayActionContext($actionContext), + ], + $this->normalizeRelayTransportResponse($response), + $success ? null : new Exception($errorMessage ?? 'Relay transport request failed') + ); + } + /** * @throws Exception */ @@ -3749,7 +3912,7 @@ BASH; private function buildBrokerPublicUrl(): ?string { - $configured = trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: '')); + $configured = $this->configuredPublicBrokerUrl(); if ($configured !== '') { return rtrim($configured, '/'); } @@ -3786,10 +3949,63 @@ BASH; private function buildBrokerInternalUrl(): ?string { - $configured = trim((string)(getenv('EDGE_BROKER_URL') ?: '')); + $configured = $this->configuredBrokerInternalUrl(); return $configured !== '' ? rtrim($configured, '/') : null; } + private function configuredPublicBrokerUrl(): string + { + try { + $configured = trim((string)(new edgegateway())->publicBrokerUrl()); + if ($configured !== '') { + return rtrim($configured, '/'); + } + } catch (Exception) { + } + + $fallback = trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: '')); + return $fallback !== '' ? rtrim($fallback, '/') : ''; + } + + private function configuredBrokerInternalUrl(): string + { + try { + $configured = trim((string)(new edgegateway())->brokerUrl()); + if ($configured !== '') { + return rtrim($configured, '/'); + } + } catch (Exception) { + } + + $fallback = trim((string)(getenv('EDGE_BROKER_URL') ?: '')); + return $fallback !== '' ? rtrim($fallback, '/') : ''; + } + + private function configuredBrokerAuthMode(): string + { + try { + $configured = trim((string)(new edgegateway())->brokerAuthMode()); + return $configured !== '' ? $configured : 'manager'; + } catch (Exception) { + } + + $fallback = trim((string)(getenv('EDGE_AUTH_MODE') ?: '')); + return $fallback !== '' ? $fallback : 'manager'; + } + + private function configuredBrokerSharedSecret(): string + { + try { + $configured = trim((string)(new edgegateway())->brokerSharedSecret()); + if ($configured !== '') { + return $configured; + } + } catch (Exception) { + } + + return trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: '')); + } + private function resolveGatewayPreferredCommandChannel(edge_gateways_o|array $gateway): string { $gatewayId = is_array($gateway) ? (int)($gateway['id'] ?? 0) : (int)$gateway->id; @@ -3803,14 +4019,350 @@ BASH; public function validateBrokerSharedSecret(?string $secret): bool { - $configured = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: '')); + $configured = $this->configuredBrokerSharedSecret(); if ($configured === '') { - return true; + return false; } return $secret !== null && hash_equals($configured, trim($secret)); } + /** + * @param array $options + * @return array + */ + public function diagnoseBrokerConfiguration(array $options = []): array + { + $target = strtolower(trim((string)($options['target'] ?? 'all'))); + $target = in_array($target, ['internal', 'public', 'secret', 'all'], true) ? $target : 'all'; + + $internalUrl = $this->normalizeBrokerDiagnosticBaseUrl( + array_key_exists('broker_url', $options) ? $options['broker_url'] : $this->configuredBrokerInternalUrl() + ); + $publicConfigured = array_key_exists('public_broker_url', $options) + ? trim((string)$options['public_broker_url']) + : $this->configuredPublicBrokerUrl(); + $publicUrl = $this->normalizeBrokerDiagnosticBaseUrl( + $publicConfigured !== '' ? $publicConfigured : $this->deriveBrokerPublicUrl() + ); + $sharedSecret = array_key_exists('broker_shared_secret', $options) + ? trim((string)$options['broker_shared_secret']) + : $this->configuredBrokerSharedSecret(); + + $diagnostics = [ + 'target' => $target, + 'checked_at' => $this->now(), + 'broker_auth_mode' => array_key_exists('broker_auth_mode', $options) + ? trim((string)$options['broker_auth_mode']) + : $this->configuredBrokerAuthMode(), + 'broker_shared_secret_configured' => $sharedSecret !== '', + ]; + + if ($target === 'internal' || $target === 'all') { + $diagnostics['internal_broker_connection'] = $this->diagnoseBrokerHttpEndpoint( + $internalUrl, + 'Internal broker' + ); + } + + if ($target === 'public' || $target === 'all') { + $diagnostics['public_broker_url'] = $this->diagnoseBrokerHttpEndpoint( + $publicUrl, + 'Public broker' + ); + } + + if ($target === 'secret' || $target === 'all') { + $diagnostics['broker_shared_secret'] = $this->diagnoseBrokerSharedSecret( + $internalUrl, + $sharedSecret + ); + } + + return $diagnostics; + } + + private function deriveBrokerPublicUrl(): ?string + { + $apiBaseUrl = $this->getApiBaseUrl(); + if (trim($apiBaseUrl) === '') { + return null; + } + + return rtrim($apiBaseUrl, '/') . '/edge-broker'; + } + + /** + * @return array{url:?string,error:?string} + */ + private function normalizeBrokerDiagnosticBaseUrl(mixed $value): array + { + $url = trim((string)$value); + if ($url === '') { + return [ + 'url' => null, + 'error' => 'not_configured', + ]; + } + + $parsed = parse_url($url); + $scheme = is_array($parsed) ? strtolower((string)($parsed['scheme'] ?? '')) : ''; + $host = is_array($parsed) ? trim((string)($parsed['host'] ?? '')) : ''; + if (!is_array($parsed) || $host === '' || !in_array($scheme, ['http', 'https'], true)) { + return [ + 'url' => $url, + 'error' => 'invalid_url', + ]; + } + + return [ + 'url' => rtrim($url, '/'), + 'error' => null, + ]; + } + + /** + * @param array{url:?string,error:?string} $baseUrl + * @return array + */ + private function diagnoseBrokerHttpEndpoint(array $baseUrl, string $label): array + { + if ($baseUrl['url'] === null || $baseUrl['error'] !== null) { + return $this->brokerDiagnosticUrlFailure($baseUrl, $label); + } + + $health = $this->brokerHttpProbe($baseUrl['url'] . '/api/health'); + if (($health['status_code'] ?? null) === 200 && !empty($health['json']['ok'])) { + return array_merge($health, [ + 'ok' => true, + 'status' => 'connected', + 'url' => $baseUrl['url'], + 'message' => $label . ' responded to the health check.', + ]); + } + + if (($health['status_code'] ?? null) === 404 && $this->isBrokerNotFoundProbe($health)) { + return array_merge($health, [ + 'ok' => true, + 'status' => 'connected_legacy', + 'url' => $baseUrl['url'], + 'message' => $label . ' responded, but the health endpoint is not deployed yet.', + ]); + } + + if (($health['status_code'] ?? null) !== null) { + return array_merge($health, [ + 'ok' => false, + 'status' => 'unexpected_response', + 'url' => $baseUrl['url'], + 'message' => $label . ' returned HTTP ' . (string)$health['status_code'] . ' instead of the broker health response.', + ]); + } + + return array_merge($health, [ + 'ok' => false, + 'status' => 'unreachable', + 'url' => $baseUrl['url'], + 'message' => $label . ' did not respond.', + ]); + } + + /** + * @param array{url:?string,error:?string} $baseUrl + * @return array + */ + private function diagnoseBrokerSharedSecret(array $baseUrl, string $sharedSecret): array + { + if ($baseUrl['url'] === null || $baseUrl['error'] !== null) { + return $this->brokerDiagnosticUrlFailure($baseUrl, 'Internal broker'); + } + + $headers = $sharedSecret !== '' ? ['x-edge-broker-secret: ' . $sharedSecret] : []; + $diagnostic = $this->brokerHttpProbe( + $baseUrl['url'] . '/api/diagnostics/shared-secret', + 'POST', + [], + $headers + ); + + if (($diagnostic['status_code'] ?? null) === 200 && !empty($diagnostic['json']['ok'])) { + $required = (bool)($diagnostic['json']['shared_secret_required'] ?? false); + return array_merge($diagnostic, [ + 'ok' => true, + 'status' => $required ? 'validated' : 'not_required', + 'url' => $baseUrl['url'], + 'message' => $required + ? 'Broker accepted the configured shared secret.' + : 'Broker responded and does not currently require a shared secret.', + ]); + } + + if (($diagnostic['status_code'] ?? null) === 403) { + return array_merge($diagnostic, [ + 'ok' => false, + 'status' => 'secret_rejected', + 'url' => $baseUrl['url'], + 'message' => 'Broker rejected the configured shared secret.', + ]); + } + + if (($diagnostic['status_code'] ?? null) === 404 && $this->isBrokerNotFoundProbe($diagnostic)) { + return $this->diagnoseBrokerSharedSecretWithLegacySync($baseUrl, $headers); + } + + if (($diagnostic['status_code'] ?? null) !== null) { + return array_merge($diagnostic, [ + 'ok' => false, + 'status' => 'unexpected_response', + 'url' => $baseUrl['url'], + 'message' => 'Broker returned HTTP ' . (string)$diagnostic['status_code'] . ' during shared secret validation.', + ]); + } + + return array_merge($diagnostic, [ + 'ok' => false, + 'status' => 'unreachable', + 'url' => $baseUrl['url'], + 'message' => 'Internal broker did not respond during shared secret validation.', + ]); + } + + /** + * @param array{url:?string,error:?string} $baseUrl + * @param array $headers + * @return array + */ + private function diagnoseBrokerSharedSecretWithLegacySync(array $baseUrl, array $headers): array + { + $legacy = $this->brokerHttpProbe( + $baseUrl['url'] . '/api/gateways/0/sync', + 'POST', + ['diagnostic' => true], + $headers + ); + + if (($legacy['status_code'] ?? null) === 200 && !empty($legacy['json']['ok'])) { + return array_merge($legacy, [ + 'ok' => true, + 'status' => 'validated_legacy', + 'url' => $baseUrl['url'], + 'message' => 'Broker accepted the shared secret through the legacy sync endpoint.', + ]); + } + + if (($legacy['status_code'] ?? null) === 403) { + return array_merge($legacy, [ + 'ok' => false, + 'status' => 'secret_rejected', + 'url' => $baseUrl['url'], + 'message' => 'Broker rejected the configured shared secret.', + ]); + } + + return array_merge($legacy, [ + 'ok' => false, + 'status' => ($legacy['status_code'] ?? null) === null ? 'unreachable' : 'unexpected_response', + 'url' => $baseUrl['url'], + 'message' => 'Broker shared secret could not be validated.', + ]); + } + + /** + * @param array{url:?string,error:?string} $baseUrl + * @return array + */ + private function brokerDiagnosticUrlFailure(array $baseUrl, string $label): array + { + $error = (string)($baseUrl['error'] ?? 'not_configured'); + return [ + 'ok' => false, + 'status' => $error, + 'url' => $baseUrl['url'], + 'status_code' => null, + 'elapsed_ms' => 0, + 'message' => $error === 'invalid_url' + ? $label . ' URL is not a valid http(s) URL.' + : $label . ' URL is not configured.', + ]; + } + + /** + * @param array $response + */ + private function isBrokerNotFoundProbe(array $response): bool + { + $json = isset($response['json']) && is_array($response['json']) ? (array)$response['json'] : []; + return strtolower(trim((string)($json['error'] ?? ''))) === 'not found'; + } + + /** + * @param array $headers + * @return array + */ + private function brokerHttpProbe( + string $url, + string $method = 'GET', + ?array $payload = null, + array $headers = [], + int $timeoutSeconds = 3 + ): array { + $method = strtoupper(trim($method)) ?: 'GET'; + $requestHeaders = array_filter(array_merge(['Accept: application/json'], $headers)); + $options = [ + 'method' => $method, + 'header' => implode("\r\n", $requestHeaders), + 'timeout' => max(1, $timeoutSeconds), + 'ignore_errors' => true, + ]; + + if ($payload !== null) { + $requestHeaders[] = 'Content-Type: application/json'; + $options['header'] = implode("\r\n", array_filter($requestHeaders)); + $options['content'] = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + $context = stream_context_create(['http' => $options]); + $started = microtime(true); + $body = @file_get_contents($url, false, $context); + $elapsedMs = (int)round((microtime(true) - $started) * 1000); + $responseHeaders = is_array($http_response_header ?? null) ? $http_response_header : []; + $statusCode = $this->parseHttpStatusCode($responseHeaders); + + if ($body === false) { + $lastError = error_get_last(); + return [ + 'status_code' => $statusCode, + 'elapsed_ms' => $elapsedMs, + 'error' => isset($lastError['message']) ? self::trimInstallSessionText($lastError['message'], 512) : null, + 'json' => null, + 'body_excerpt' => null, + ]; + } + + $decoded = json_decode($body, true); + return [ + 'status_code' => $statusCode, + 'elapsed_ms' => $elapsedMs, + 'error' => null, + 'json' => is_array($decoded) ? $decoded : null, + 'body_excerpt' => self::trimInstallSessionText($body, 512), + ]; + } + + /** + * @param array $headers + */ + private function parseHttpStatusCode(array $headers): ?int + { + foreach ($headers as $header) { + if (preg_match('/^HTTP\/\S+\s+(\d{3})\b/i', trim((string)$header), $matches) === 1) { + return (int)$matches[1]; + } + } + + return null; + } + /** * @throws Exception */ @@ -3858,7 +4410,7 @@ BASH; $this->httpJsonRequest( $brokerUrl . '/api/gateways/' . $gatewayId . '/sync', ['gatewayId' => $gatewayId], - ['x-edge-broker-secret: ' . trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''))], + ['x-edge-broker-secret: ' . $this->configuredBrokerSharedSecret()], self::BROKER_HTTP_TIMEOUT_SECONDS ); } catch (Exception) { @@ -4005,6 +4557,557 @@ BASH; return $toggleAfterSeconds; } + private function currentRelayActionContext(): array + { + $context = []; + foreach (self::$relayActionContextStack as $entry) { + if (is_array($entry)) { + $context = array_replace_recursive($context, $entry); + } + } + + return $context; + } + + private function normalizeRelayActionContext(array $context): array + { + $context = array_replace_recursive($this->currentRelayActionContext(), $context); + $module = trim((string)($context['module'] ?? $context['module_responsible'] ?? '')); + $reason = trim((string)($context['reason'] ?? $context['action_reason'] ?? '')); + $context['module'] = $module !== '' ? $module : 'edge_gateway'; + $context['module_responsible'] = $context['module']; + $context['reason'] = $reason !== '' ? $reason : 'Relay dispatch'; + + $actor = array_replace( + $this->resolveRelayAuthenticatedActorContext(), + isset($context['actor']) && is_array($context['actor']) ? (array)$context['actor'] : [] + ); + foreach (['user_id', 'admin_user_id', 'customer_user_id', 'customer_number', 'subuser_id', 'type', 'display_name'] as $key) { + if (array_key_exists($key, $context) && !array_key_exists($key, $actor)) { + $actor[$key] = $context[$key]; + } + } + $context['actor'] = $this->compactRelayLogArray($actor); + + $associated = isset($context['associated']) && is_array($context['associated']) + ? (array)$context['associated'] + : []; + foreach (['admin_user_id', 'customer_user_id', 'customer_number', 'subuser_id'] as $key) { + if (isset($context['actor'][$key]) && !isset($associated[$key])) { + $associated[$key] = $context['actor'][$key]; + } + if (array_key_exists($key, $context) && !isset($associated[$key])) { + $associated[$key] = $context[$key]; + } + } + $context['associated'] = $this->compactRelayLogArray($associated); + + return $this->compactRelayLogArray($context); + } + + private function resolveRelayAuthenticatedActorContext(): array + { + if (!function_exists('getallheaders')) { + return []; + } + + try { + $headers = getallheaders(); + } catch (\Throwable) { + return []; + } + if (!is_array($headers) || $this->relayHeaderValue($headers, 'Authorization') === null) { + return []; + } + + try { + $user = (new authentication())->get_user(); + } catch (\Throwable) { + return []; + } + + if (!$user instanceof \objects\users_o || !$user->exists()) { + return []; + } + + $userId = (int)$user->id; + $customerNumber = isset($user->customer_number) + ? (int)($user->customer_number->value() ?? 0) + : 0; + $displayName = isset($user->display_name) ? trim((string)($user->display_name->value() ?? '')) : ''; + $actor = [ + 'user_id' => $userId, + 'type' => $customerNumber > 0 ? 'customer' : 'admin', + ]; + if ($displayName !== '') { + $actor['display_name'] = $displayName; + } + if ($customerNumber > 0) { + $actor['customer_user_id'] = $userId; + $actor['customer_number'] = $customerNumber; + } else { + $actor['admin_user_id'] = $userId; + } + + return $actor; + } + + private function relayHeaderValue(array $headers, string $name): ?string + { + $normalized = strtolower($name); + foreach ($headers as $key => $value) { + if (strtolower((string)$key) === $normalized) { + $value = trim((string)$value); + return $value !== '' ? $value : null; + } + } + + return null; + } + + private function resolveRelayRequestedBy(array $actionContext): ?int + { + $actor = isset($actionContext['actor']) && is_array($actionContext['actor']) ? (array)$actionContext['actor'] : []; + foreach (['admin_user_id', 'user_id', 'customer_user_id'] as $key) { + $id = isset($actor[$key]) ? (int)$actor[$key] : 0; + if ($id > 0) { + return $id; + } + } + + return null; + } + + private function buildRelayCommandSignal(edge_gateway_command_jobs_o $job, array $request): array + { + $delivery = (array)($job->delivery_json->value() ?? []); + return $this->compactRelayLogArray([ + 'command_type' => (string)$job->command_type->value(), + 'job_id' => (int)$job->id, + 'correlation_id' => (string)$job->correlation_id->value(), + 'request' => $request, + 'preferred_channel' => $delivery['preferred_channel'] ?? null, + 'require_fast_path' => !empty($delivery['require_fast_path']), + 'fallback_reason' => $delivery['fallback_reason'] ?? null, + ]); + } + + private function appendRelayDispatchLog( + array $binding, + array $resolution, + bool $success, + array $dispatchLog, + array $result = [], + ?\Throwable $exception = null + ): ?array { + try { + $gatewayId = (int)($binding['gateway_id'] ?? 0); + if ($gatewayId <= 0) { + return null; + } + + $context = $this->buildRelayLogContext($binding, $resolution, $success, $dispatchLog, $result, $exception); + return $this->appendGatewayLogEntry( + $gatewayId, + $this->buildRelayLogMessage($context, $success), + $success ? 'INFO' : 'ERROR', + 'relay', + 'RELAY_DISPATCH', + $context + ); + } catch (\Throwable) { + return null; + } + } + + private function buildRelayLogContext( + array $binding, + array $resolution, + bool $success, + array $dispatchLog, + array $result, + ?\Throwable $exception + ): array { + $actionContext = $this->normalizeRelayActionContext( + isset($dispatchLog['action_context']) && is_array($dispatchLog['action_context']) + ? (array)$dispatchLog['action_context'] + : [] + ); + $handler = strtolower(trim((string)($dispatchLog['handler'] ?? $resolution['execution_path'] ?? 'local'))); + $handler = $handler === 'cloud' ? 'cloud' : 'local'; + $deliveryChannel = (string)($resolution['delivery_channel'] + ?? ($handler === 'cloud' ? self::DELIVERY_CHANNEL_CLOUD : ($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API))); + $relayId = trim((string)($dispatchLog['relay_id'] ?? $binding['relay_id'] ?? '')); + $signal = isset($dispatchLog['signal']) && is_array($dispatchLog['signal']) + ? (array)$dispatchLog['signal'] + : []; + if ($relayId !== '' && !isset($signal['relay_id'])) { + $signal['relay_id'] = $relayId; + } + $relayRole = $this->normalizeRelayLogRole( + $actionContext['relay_role'] + ?? $actionContext['role'] + ?? $binding['metadata']['relay_role'] + ?? $binding['metadata']['role'] + ?? null + ); + $relayName = $this->resolveRelayLogDisplayName($binding, $actionContext, $signal); + + $context = [ + 'success' => $success, + 'module' => (string)$actionContext['module'], + 'module_responsible' => (string)$actionContext['module_responsible'], + 'reason' => (string)$actionContext['reason'], + 'handler' => $handler, + 'execution_path' => $handler, + 'delivery_channel' => $deliveryChannel, + 'department_id' => isset($binding['department_id']) ? (int)$binding['department_id'] : null, + 'gateway_id' => isset($binding['gateway_id']) ? (int)$binding['gateway_id'] : null, + 'relay_id' => $relayId !== '' ? $relayId : null, + 'relay_name' => $relayName, + 'relay_role' => $relayRole, + 'action' => strtoupper(trim((string)($dispatchLog['action'] ?? 'RELAY'))), + 'target_on' => array_key_exists('target_on', $dispatchLog) ? $dispatchLog['target_on'] : null, + 'toggle_after_seconds' => $dispatchLog['toggle_after_seconds'] ?? null, + 'actor' => isset($actionContext['actor']) && is_array($actionContext['actor']) ? (array)$actionContext['actor'] : [], + 'associated' => isset($actionContext['associated']) && is_array($actionContext['associated']) ? (array)$actionContext['associated'] : [], + 'binding' => $this->relayLogBindingPayload($binding), + 'execution' => $this->compactRelayLogArray([ + 'path' => $handler, + 'channel' => $deliveryChannel, + 'reason' => $resolution['reason'] ?? null, + 'fallback_reason' => $resolution['fallback_reason'] ?? null, + 'fallback_mode' => $resolution['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL, + 'recommended_action' => $resolution['recommended_action'] ?? null, + ]), + 'signal' => $this->sanitizeRelayLogValue($signal), + 'response' => $this->relayLogResponsePayload($result), + ]; + + if ($exception !== null) { + $context['error'] = [ + 'message' => $exception->getMessage(), + 'type' => $exception::class, + ]; + } + + $extraContext = $actionContext; + unset( + $extraContext['module'], + $extraContext['module_responsible'], + $extraContext['reason'], + $extraContext['actor'], + $extraContext['associated'], + $extraContext['admin_user_id'], + $extraContext['customer_user_id'], + $extraContext['customer_number'], + $extraContext['subuser_id'], + $extraContext['relay_name'], + $extraContext['relay_label'], + $extraContext['relay_role'], + $extraContext['role'] + ); + if ($extraContext !== []) { + $context['action_context'] = $this->sanitizeRelayLogValue($extraContext); + } + + $context = $this->compactRelayLogArray($context); + $description = $this->buildRelayLogDescription($context, $success); + if ($description !== '') { + $context['description'] = $description; + } + + return $this->compactRelayLogArray($context); + } + + private function buildRelayLogMessage(array $context, bool $success): string + { + $description = trim((string)($context['description'] ?? '')); + if ($description !== '') { + return $description; + } + + return $this->buildRelayLogDescription($context, $success); + } + + private function buildRelayLogDescription(array $context, bool $success): string + { + $subject = $this->buildRelayLogSubject($context); + $handler = strtolower(trim((string)($context['handler'] ?? 'local'))); + $channel = trim((string)($context['delivery_channel'] ?? '')); + + if (!$success) { + return trim(sprintf('%s failed via %s', $subject, $handler)); + } + + return trim(sprintf( + '%s handled by %s%s', + $subject, + $handler, + $channel !== '' ? ' via ' . $channel : '' + )); + } + + private function buildRelayLogSubject(array $context): string + { + $action = strtoupper(trim((string)($context['action'] ?? 'RELAY'))); + $relayId = trim((string)($context['relay_id'] ?? 'unknown')); + $relayName = trim((string)($context['relay_name'] ?? '')); + $relayTarget = $relayName !== '' ? $relayName : $relayId; + $relayRole = $this->normalizeRelayLogRole($context['relay_role'] ?? null); + $state = $this->resolveRelayLogState($context); + + if (in_array($relayRole, ['ENTRY', 'EXIT'], true)) { + $verb = $state === false ? 'Close' : 'Open'; + return trim(sprintf('%s %s %s', $verb, $relayRole, $relayTarget)); + } + + if (in_array($relayRole, ['MACHINE', 'PROGRAM_PICKER', 'CLEANER'], true) && $state !== null) { + return trim(sprintf('%s %s %s', $relayRole, $state ? 'ON' : 'OFF', $relayTarget)); + } + + $targetState = $state !== null ? ($state ? ' ON' : ' OFF') : ''; + return trim(sprintf('Relay %s %s%s', $action, $relayTarget, $targetState)); + } + + private function resolveRelayLogState(array $context): ?bool + { + if (array_key_exists('target_on', $context) && $context['target_on'] !== null) { + return (bool)$context['target_on']; + } + + $signal = isset($context['signal']) && is_array($context['signal']) ? (array)$context['signal'] : []; + $request = isset($signal['request']) && is_array($signal['request']) ? (array)$signal['request'] : []; + if (array_key_exists('on', $request)) { + return (bool)$request['on']; + } + + $response = isset($context['response']) && is_array($context['response']) ? (array)$context['response'] : []; + if (array_key_exists('on', $response)) { + return (bool)$response['on']; + } + + return null; + } + + private function normalizeRelayLogRole(mixed $role): ?string + { + $normalized = strtoupper(trim((string)($role ?? ''))); + if ($normalized === '') { + return null; + } + + return match ($normalized) { + 'ENTRANCE', 'IN', 'INLET', 'ENTRY_GATE' => 'ENTRY', + 'OUT', 'OUTLET', 'EXIT_GATE' => 'EXIT', + 'MACHINE_PROGRAM_PICKER', 'PROGRAM_SELECTOR', 'PICKER' => 'PROGRAM_PICKER', + 'MACHINE_CLEANER' => 'CLEANER', + default => $normalized, + }; + } + + private function resolveRelayLogDisplayName(array $binding, array $actionContext, array $signal): ?string + { + $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; + $request = isset($signal['request']) && is_array($signal['request']) ? (array)$signal['request'] : []; + $relayId = trim((string)($signal['relay_id'] ?? $binding['relay_id'] ?? $request['relayId'] ?? $request['id'] ?? '')); + + $directName = $this->firstRelayLogString([ + $actionContext['relay_name'] ?? null, + $actionContext['relay_label'] ?? null, + $metadata['relay_name'] ?? null, + $metadata['relay_label'] ?? null, + $metadata['name'] ?? null, + $metadata['label'] ?? null, + $request['relay_name'] ?? null, + $request['relay_label'] ?? null, + ]); + if ($directName !== null) { + return $directName; + } + + $departmentName = $this->findDepartmentRelayName( + isset($binding['department_id']) ? (int)$binding['department_id'] : 0, + $relayId + ); + if ($departmentName !== null) { + return $departmentName; + } + + foreach ($this->listShellyRelayOptionsForLocalIpLookup() as $option) { + $optionRelayId = trim((string)($option['id'] ?? '')); + if ($optionRelayId === '' || $optionRelayId !== $relayId) { + continue; + } + + $optionName = $this->firstRelayLogString([ + $option['name'] ?? null, + $option['label'] ?? null, + $option['device_name'] ?? null, + ]); + if ($optionName !== null) { + return $optionName; + } + } + + return null; + } + + private function firstRelayLogString(array $candidates): ?string + { + foreach ($candidates as $candidate) { + $value = trim((string)($candidate ?? '')); + if ($value !== '') { + return $value; + } + } + + return null; + } + + private function findDepartmentRelayName(int $departmentId, string $relayId): ?string + { + if ($departmentId <= 0 || trim($relayId) === '') { + return null; + } + + try { + $rows = (new department_relays_o())->getFieldsWhere([ + 'department' => $departmentId, + 'relay_id' => $relayId, + 'deleted_at' => null, + ], ['id', 'name']); + } catch (\Throwable) { + return null; + } + + foreach ($rows as $row) { + $name = trim((string)($row['name'] ?? '')); + if ($name !== '') { + return $name; + } + } + + return null; + } + + private function relayLogBindingPayload(array $binding): array + { + $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; + + return $this->compactRelayLogArray([ + 'id' => isset($binding['id']) ? (int)$binding['id'] : null, + 'gateway_id' => isset($binding['gateway_id']) ? (int)$binding['gateway_id'] : null, + 'department_id' => isset($binding['department_id']) ? (int)$binding['department_id'] : null, + 'relay_id' => $binding['relay_id'] ?? null, + 'relay_name' => $this->firstRelayLogString([ + $metadata['relay_name'] ?? null, + $metadata['relay_label'] ?? null, + $metadata['name'] ?? null, + $metadata['label'] ?? null, + ]), + 'device_id' => $binding['device_id'] ?? null, + 'local_ip' => $binding['local_ip'] ?? null, + 'channel' => isset($binding['channel']) ? (int)$binding['channel'] : null, + ]); + } + + private function relayLogResponsePayload(array $result): array + { + if ($result === []) { + return []; + } + + return $this->compactRelayLogArray([ + 'online' => array_key_exists('online', $result) ? (bool)$result['online'] : null, + 'on' => array_key_exists('on', $result) ? (bool)$result['on'] : null, + 'raw' => $this->sanitizeRelayLogValue($result['raw'] ?? $result), + ]); + } + + private function normalizeRelayTransportResponse(array|object|null $response): array + { + if ($response === null) { + return []; + } + + $normalized = is_array($response) ? (array)($response[0] ?? $response) : (array)$response; + return [ + 'online' => (bool)($normalized['online'] ?? true), + 'on' => (bool)($normalized['on'] + ?? $normalized['output'] + ?? $normalized['status']['switch:0']['output'] + ?? false), + 'raw' => (array)($normalized['raw'] ?? $normalized), + ]; + } + + private function inferRelayIdFromTransportPayload(array $payload): ?string + { + $relayId = trim((string)($payload['id'] ?? $payload['relayId'] ?? $payload['relay_id'] ?? '')); + if ($relayId !== '') { + return $relayId; + } + + $ids = (array)($payload['ids'] ?? []); + foreach ($ids as $id) { + $relayId = trim((string)$id); + if ($relayId !== '') { + return $relayId; + } + } + + return null; + } + + private function inferRelayActionFromEndpoint(string $endpoint): string + { + return str_contains(strtolower($endpoint), '/get') ? 'STATUS' : 'SWITCH'; + } + + private function compactRelayLogArray(array $value): array + { + return array_filter( + $value, + static fn(mixed $entry): bool => $entry !== null && $entry !== '' && $entry !== [] + ); + } + + private function sanitizeRelayLogValue(mixed $value, int $depth = 0): mixed + { + if ($depth > 5) { + return '[truncated]'; + } + + if (is_object($value)) { + $value = (array)$value; + } + + if (is_array($value)) { + $result = []; + $count = 0; + foreach ($value as $key => $entry) { + if ($count >= 80) { + $result['__truncated'] = true; + break; + } + $result[$key] = $this->sanitizeRelayLogValue($entry, $depth + 1); + $count++; + } + return $result; + } + + if (is_string($value) && strlen($value) > 2000) { + return substr($value, 0, 2000) . '...'; + } + + if (is_scalar($value) || $value === null) { + return $value; + } + + return (string)$value; + } + /** * @throws Exception */ @@ -4127,7 +5230,7 @@ BASH; 'payload' => $this->buildCommandExecutionPayload($job, $gateway), ], [ - 'x-edge-broker-secret: ' . trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: '')), + 'x-edge-broker-secret: ' . $this->configuredBrokerSharedSecret(), ], self::BROKER_HTTP_TIMEOUT_SECONDS ); @@ -4148,16 +5251,46 @@ BASH; ?bool $on, array $binding, array $resolution, - ?int $toggleAfterSeconds = null + ?int $toggleAfterSeconds = null, + array $actionContext = [] ): array { - $transport = new cloud_shelly_transport(); + $transport = new cloud_shelly_transport(null, false); $setPayload = ['id' => $logicalRelayId, 'on' => $on]; if ($on !== null && $toggleAfterSeconds !== null) { $setPayload['toggle_after'] = $toggleAfterSeconds; } - $response = $on === null - ? $transport->sendPostRequest('/v2/devices/api/get', ['ids' => [$logicalRelayId]], $departmentId) - : $transport->sendPostRequest('/v2/devices/api/set/switch', $setPayload, $departmentId); + $endpoint = $on === null ? '/v2/devices/api/get' : '/v2/devices/api/set/switch'; + $request = $on === null ? ['ids' => [$logicalRelayId]] : $setPayload; + $actionContext = $this->normalizeRelayActionContext($actionContext); + $dispatchLog = [ + 'action' => $on === null ? 'STATUS' : 'SWITCH', + 'handler' => 'cloud', + 'relay_id' => $logicalRelayId, + 'target_on' => $on, + 'toggle_after_seconds' => $toggleAfterSeconds, + 'signal' => [ + 'endpoint' => $endpoint, + 'request' => $request, + ], + 'action_context' => $actionContext, + ]; + + try { + $response = $transport->sendPostRequest($endpoint, $request, $departmentId); + } catch (\Throwable $exception) { + $this->appendRelayDispatchLog( + $binding, + array_merge($resolution, [ + 'execution_path' => 'cloud', + 'delivery_channel' => self::DELIVERY_CHANNEL_CLOUD, + ]), + false, + $dispatchLog, + [], + $exception + ); + throw $exception; + } $normalized = is_array($response) ? (array)($response[0] ?? []) : (array)$response; $result = [ @@ -4176,7 +5309,8 @@ BASH; 'execution_path' => 'cloud', 'delivery_channel' => self::DELIVERY_CHANNEL_CLOUD, ]), - $result + $result, + $dispatchLog ); } @@ -4190,7 +5324,8 @@ BASH; array $resolution, ?bool $on, Exception $exception, - ?int $toggleAfterSeconds = null + ?int $toggleAfterSeconds = null, + array $actionContext = [] ): array { $recommendedAction = $this->mapRelayFailureToRecommendedAction($exception->getMessage()); $this->recordRelayBindingResolution( @@ -4223,7 +5358,8 @@ BASH; 'recommended_action' => $recommendedAction, 'recovery_actions' => [$recommendedAction, 'force_cloud'], ]), - $toggleAfterSeconds + $toggleAfterSeconds, + $actionContext ); } catch (Exception $cloudException) { $this->recordRelayBindingResolution( @@ -4241,7 +5377,7 @@ BASH; } } - private function finalizeRelayDispatch(array $binding, array $resolution, array $result): array + private function finalizeRelayDispatch(array $binding, array $resolution, array $result, array $dispatchLog = []): array { $executionPath = (string)($resolution['execution_path'] ?? 'local'); $deliveryChannel = $executionPath === 'cloud' @@ -4253,6 +5389,9 @@ BASH; 'execution_path' => $executionPath, ]); $this->recordRelayBindingResolution($binding, $resolutionPayload, true, null); + if ($dispatchLog !== []) { + $this->appendRelayDispatchLog($binding, $resolutionPayload, true, $dispatchLog, $result, null); + } return array_merge($result, [ 'binding' => $this->reloadRelayBinding((int)$binding['id']), @@ -5651,7 +6790,7 @@ BASH; return $secret; } - $fallback = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: '')); + $fallback = $this->configuredBrokerSharedSecret(); if ($fallback !== '') { return $fallback; } diff --git a/services/nginx/app/modules/edgegateway/config/edgegateway_broker_auth_mode_c.php b/services/nginx/app/modules/edgegateway/config/edgegateway_broker_auth_mode_c.php new file mode 100644 index 00000000..278a6e2b --- /dev/null +++ b/services/nginx/app/modules/edgegateway/config/edgegateway_broker_auth_mode_c.php @@ -0,0 +1,29 @@ +setupConfigVariable( + 'edgegateway', + 'broker_auth_mode', + 'string', + true, + ['manager', 'stub'], + 'The broker authentication mode. Production should use manager.', + 'manager', + false, + trim((string)(getenv('EDGE_AUTH_MODE') ?: '')) ?: 'manager' + ); + } +} diff --git a/services/nginx/app/modules/edgegateway/config/edgegateway_broker_shared_secret_c.php b/services/nginx/app/modules/edgegateway/config/edgegateway_broker_shared_secret_c.php new file mode 100644 index 00000000..bb226e06 --- /dev/null +++ b/services/nginx/app/modules/edgegateway/config/edgegateway_broker_shared_secret_c.php @@ -0,0 +1,29 @@ +setupConfigVariable( + 'edgegateway', + 'broker_shared_secret', + 'string', + false, + null, + 'The shared secret used between the edge broker and PHP manager callbacks.', + 'truckwash-edge-dev', + true, + trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: '')) + ); + } +} diff --git a/services/nginx/app/modules/edgegateway/config/edgegateway_broker_url_c.php b/services/nginx/app/modules/edgegateway/config/edgegateway_broker_url_c.php new file mode 100644 index 00000000..b64e1190 --- /dev/null +++ b/services/nginx/app/modules/edgegateway/config/edgegateway_broker_url_c.php @@ -0,0 +1,29 @@ +setupConfigVariable( + 'edgegateway', + 'broker_url', + 'string', + false, + null, + 'The internal HTTP URL used by PHP workers to reach the edge broker service.', + 'http://edge-broker:4300', + false, + trim((string)(getenv('EDGE_BROKER_URL') ?: '')) ?: 'http://edge-broker:4300' + ); + } +} diff --git a/services/nginx/app/modules/edgegateway/config/edgegateway_public_broker_url_c.php b/services/nginx/app/modules/edgegateway/config/edgegateway_public_broker_url_c.php new file mode 100644 index 00000000..51bc1195 --- /dev/null +++ b/services/nginx/app/modules/edgegateway/config/edgegateway_public_broker_url_c.php @@ -0,0 +1,29 @@ +setupConfigVariable( + 'edgegateway', + 'public_broker_url', + 'string', + false, + null, + 'The browser-routable edge broker URL, including the proxy path prefix.', + 'https://api-v2.truckwash.io/edge-broker', + false, + trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: '')) ?: 'https://api-v2.truckwash.io/edge-broker' + ); + } +} diff --git a/services/nginx/app/modules/edgegateway/edgegateway_c.php b/services/nginx/app/modules/edgegateway/edgegateway_c.php index 0f0a8b4a..81d82628 100644 --- a/services/nginx/app/modules/edgegateway/edgegateway_c.php +++ b/services/nginx/app/modules/edgegateway/edgegateway_c.php @@ -5,10 +5,18 @@ namespace modules\edgegateway; require_once WD . '/modules/edgegateway/config/edgegateway_enabled_c.php'; require_once WD . '/modules/edgegateway/config/edgegateway_default_release_channel_c.php'; require_once WD . '/modules/edgegateway/config/edgegateway_default_update_window_c.php'; +require_once WD . '/modules/edgegateway/config/edgegateway_broker_url_c.php'; +require_once WD . '/modules/edgegateway/config/edgegateway_public_broker_url_c.php'; +require_once WD . '/modules/edgegateway/config/edgegateway_broker_auth_mode_c.php'; +require_once WD . '/modules/edgegateway/config/edgegateway_broker_shared_secret_c.php'; +use modules\edgegateway\config\edgegateway_broker_auth_mode_c; +use modules\edgegateway\config\edgegateway_broker_shared_secret_c; +use modules\edgegateway\config\edgegateway_broker_url_c; use modules\edgegateway\config\edgegateway_default_release_channel_c; use modules\edgegateway\config\edgegateway_default_update_window_c; use modules\edgegateway\config\edgegateway_enabled_c; +use modules\edgegateway\config\edgegateway_public_broker_url_c; use traits\module_config_t; class edgegateway_c @@ -18,6 +26,10 @@ class edgegateway_c public edgegateway_enabled_c $enabled; public edgegateway_default_release_channel_c $default_release_channel; public edgegateway_default_update_window_c $default_update_window; + public edgegateway_broker_url_c $broker_url; + public edgegateway_public_broker_url_c $public_broker_url; + public edgegateway_broker_auth_mode_c $broker_auth_mode; + public edgegateway_broker_shared_secret_c $broker_shared_secret; public function __construct() { @@ -26,9 +38,16 @@ class edgegateway_c edgegateway_enabled_c::class, edgegateway_default_release_channel_c::class, edgegateway_default_update_window_c::class, + edgegateway_broker_url_c::class, + edgegateway_public_broker_url_c::class, + edgegateway_broker_auth_mode_c::class, ]); $this->enabled = new edgegateway_enabled_c(); $this->default_release_channel = new edgegateway_default_release_channel_c(); $this->default_update_window = new edgegateway_default_update_window_c(); + $this->broker_url = new edgegateway_broker_url_c(); + $this->public_broker_url = new edgegateway_public_broker_url_c(); + $this->broker_auth_mode = new edgegateway_broker_auth_mode_c(); + $this->broker_shared_secret = new edgegateway_broker_shared_secret_c(); } } diff --git a/services/nginx/app/modules/edgegateway/routes/edgeGatewayConfigRoute.php b/services/nginx/app/modules/edgegateway/routes/edgeGatewayConfigRoute.php index ae2492bb..103acb32 100644 --- a/services/nginx/app/modules/edgegateway/routes/edgeGatewayConfigRoute.php +++ b/services/nginx/app/modules/edgegateway/routes/edgeGatewayConfigRoute.php @@ -3,6 +3,7 @@ namespace routes; use classes\authentication; +use classes\edge_gateway_manager; use classes\edgegateway; use classes\response; use objects\logs_o; @@ -20,6 +21,9 @@ class edgeGatewayConfigRoute $this->post('/edgegateway/config', fn() => $this->handlePostConfig(), [ 'modules_shelly_config' => 'Update edge gateway config', ]); + $this->post('/edgegateway/config/broker-diagnostics', fn() => $this->handleBrokerDiagnostics(), [ + 'modules_shelly_config' => 'Test edge gateway broker configuration', + ]); } private function handleGetConfig(): void @@ -35,7 +39,15 @@ class edgeGatewayConfigRoute } (new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully fetched edge gateway config'); - $response->success((new edgegateway())->config->getConfigRequest()); + $config = (new edgegateway())->config->getConfigRequest(); + foreach ($config as &$entry) { + if (($entry['variable'] ?? null) === 'broker_shared_secret') { + $entry['value'] = ''; + } + } + unset($entry); + + $response->success($config); } private function handlePostConfig(): void @@ -53,4 +65,21 @@ class edgeGatewayConfigRoute (new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully updated edge gateway config'); $response->success((new edgegateway())->config->postConfigRequest()); } + + private function handleBrokerDiagnostics(): void + { + global /** @var response $response */ $response; + $this->requirePermission('modules_shelly_config'); + $user = (new authentication())->get_user(); + + if (!$user) { + (new logs_o())->add('edgegateway_config', 'global', 1, 0, 'EDGEGATEWAY_BROKER_DIAGNOSTICS', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + return; + } + + $payload = self::getParametersAsArray(); + (new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_BROKER_DIAGNOSTICS', 'Tested edge gateway broker config'); + $response->success((new edge_gateway_manager())->diagnoseBrokerConfiguration($payload)); + } } diff --git a/services/nginx/app/modules/edgegateway/routes/edgeGatewaysRoute.php b/services/nginx/app/modules/edgegateway/routes/edgeGatewaysRoute.php index edd14b3f..3fbcd00d 100644 --- a/services/nginx/app/modules/edgegateway/routes/edgeGatewaysRoute.php +++ b/services/nginx/app/modules/edgegateway/routes/edgeGatewaysRoute.php @@ -11,6 +11,7 @@ use classes\edge_gateway_registry_service; use classes\edge_gateway_view_service; use classes\response; use Exception; +use modules\selfserve\classes\selfserve_machine_signal; use traits\route_t; class edgeGatewaysRoute @@ -98,6 +99,8 @@ class edgeGatewaysRoute $this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll()); $this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult()); $this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence()); + $this->post('/edge-agent/gateways/{id}/selfserve/machine-signal-bindings', fn() => $this->handleAgentSelfserveMachineSignalBindings()); + $this->post('/edge-agent/gateways/{id}/selfserve/machine-signal', fn() => $this->handleAgentSelfserveMachineSignal()); $this->post('/edge-agent/internal/gateways/{id}/validate', fn() => $this->handleBrokerGatewayValidate()); $this->post('/edge-agent/internal/gateways/{id}/presence', fn() => $this->handleBrokerGatewayPresence()); @@ -569,6 +572,40 @@ class edgeGatewaysRoute )); } + private function handleAgentSelfserveMachineSignalBindings(): void + { + global /** @var response $response */ $response; + $gatewayId = (int)$this->fromRoute('id'); + $payload = self::getParametersAsArray(); + + try { + $response->success((new selfserve_machine_signal())->listEdgeGatewayMachineSignalMonitors( + $gatewayId, + $this->requireAgentToken($payload) + )); + } catch (\Throwable $exception) { + $response->error($exception->getMessage(), 400); + } + } + + private function handleAgentSelfserveMachineSignal(): void + { + global /** @var response $response */ $response; + $gatewayId = (int)$this->fromRoute('id'); + $payload = self::getParametersAsArray(); + + try { + $result = (new selfserve_machine_signal())->recordEdgeGatewaySignal( + $gatewayId, + $this->requireAgentToken($payload), + $payload + ); + $response->success($result, !empty($result['recorded']) ? 201 : 202); + } catch (\Throwable $exception) { + $response->error($exception->getMessage(), 400); + } + } + private function handleBrokerGatewayValidate(): void { global /** @var response $response */ $response; diff --git a/services/nginx/app/modules/failover/config/failover_database_enabled_c.php b/services/nginx/app/modules/failover/config/failover_database_enabled_c.php new file mode 100644 index 00000000..d122d663 --- /dev/null +++ b/services/nginx/app/modules/failover/config/failover_database_enabled_c.php @@ -0,0 +1,29 @@ +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(); + } +} diff --git a/services/nginx/app/modules/forms/objects/complete_booking_f.php b/services/nginx/app/modules/forms/objects/complete_booking_f.php deleted file mode 100644 index daad4d94..00000000 --- a/services/nginx/app/modules/forms/objects/complete_booking_f.php +++ /dev/null @@ -1,103 +0,0 @@ -form_unsanitized_data as $key => $value ) { - // Sanitize the input TODO: Implement the sanitization logic - $this->form_unsanitized_data[$key] = $value; - } - // Set the sanitized data - $this->form_sanitized_data = $this->form_unsanitized_data; - } - - /** - * @inheritDoc - */ - public function validateInput(): void - { - - } - - /** - * @inheritDoc - */ - public function beforeSave(): void - { - // Get the booking from the booking id - $bookings = new bookings_o(); - $bookings->select(self::getSanitizedData('booking_id')); - // Check if the user has access to the booking - self::restrictAccessDepartment($bookings->department->value()); - // Check if the user has access to issue wash certificates - self::requirePermission( - 'issue_wash_certificates', - 'User does not have access to issue wash certificates', - ); - // Set the department id to the department id of the user - self::setDepartmentId($bookings->department->value()); - // Set the customer number to the customer number of the user - self::setCustomerNumber($bookings->customer_number->value()); - // Check if the booking is cancelled - switch ($bookings->status->value()) { - case 'cancelled': - throw new \Exception('Booking is cancelled'); - break; - case 'completed': - throw new \Exception('Booking is completed'); - break; - case 'pending': - break; - default: - throw new \Exception('Booking status is unknown, expected cancelled, completed or pending'); - break; - } - // Generate the wash certificate - $bookings->completeWashWithoutWashCertificate( - $bookings->id, - ); - } - - /** - * @inheritDoc - */ - public function afterSubmit(): void - { - // TODO: Implement afterSubmit() method. - } - - /** - * @inheritDoc - */ - public function setup(): void - { - self::setFormIdentifier('COMPLETE_BOOKING_WITHOUT_WASH_CERTIFICATE'); - self::setFormName('Bekræft vask'); - self::setFormDescription('Bekræft vask af booking'); - self::setSubmitButtonText('Bekræft vask'); - // Set the form fields - self::defineInputFieldsAdvanced([ - 'booking_id' => [ - 'description' => 'Booking ID er det unikke ID for den booking, du vil oprette et vaskecertifikat til.', - 'required' => true, - 'placeholder' => 'Indtast booking ID', - 'label' => 'Booking ID', - 'help' => 'Dette er det unikke ID for den booking, du vil oprette et vaskecertifikat til.', - 'error' => 'Du skal indtaste et gyldigt booking ID.', - 'validation_method' => 'validateBookingId', - ], - ]); - } -} \ No newline at end of file diff --git a/services/nginx/app/modules/forms/objects/generate_booking_wash_certificate_f.php b/services/nginx/app/modules/forms/objects/generate_booking_wash_certificate_f.php deleted file mode 100644 index 1cb69f5b..00000000 --- a/services/nginx/app/modules/forms/objects/generate_booking_wash_certificate_f.php +++ /dev/null @@ -1,142 +0,0 @@ -form_unsanitized_data as $key => $value ) { - // Sanitize the input TODO: Implement the sanitization logic - $this->form_unsanitized_data[$key] = $value; - } - // Set the sanitized data - $this->form_sanitized_data = $this->form_unsanitized_data; - } - - /** - * @inheritDoc - */ - public function validateInput(): void - { - - } - - /** - * @inheritDoc - */ - public function beforeSave(): void - { - // Get the booking from the booking id - $bookings = new bookings_o(); - $bookings->select(self::getSanitizedData('booking_id')); - // Check if the user has access to the booking - self::restrictAccessDepartment($bookings->department->value()); - // Check if the user has access to issue wash certificates - self::requirePermission( - 'issue_wash_certificates', - 'User does not have access to issue wash certificates', - ); - // Set the department id to the department id of the user - self::setDepartmentId($bookings->department->value()); - // Set the customer number to the customer number of the user - self::setCustomerNumber($bookings->customer_number->value()); - // Check if the booking is cancelled - switch ($bookings->status->value()) { - case 'cancelled': - throw new \Exception('Booking is cancelled'); - break; - case 'completed': - throw new \Exception('Booking is completed'); - break; - case 'pending': - break; - default: - throw new \Exception('Booking status is unknown, expected cancelled, completed or pending'); - break; - } - try { - $safety_seal = (int)self::getSanitizedData('safety_seal'); - } catch (\Exception $e) { - $safety_seal = null; - } - try { - $operator = (string)self::getSanitizedData('operator'); - } catch (\Exception $e) { - $operator = null; - } - // Generate the wash certificate - $bookings->generateWashCertificate( - $safety_seal, - $operator, - ); - // Send the wash certificate to the customer - $bookings->sendWashCertificateToCustomer(); - // Create the transaction based on the booking - //$transaction = $bookings->createTransaction(); - // Mark the booking as completed - $bookings->status->set('completed'); - $bookings->washCertificateStatus->set('completed'); - } - - /** - * @inheritDoc - */ - public function afterSubmit(): void - { - // TODO: Implement afterSubmit() method. - } - - /** - * @inheritDoc - */ - public function setup(): void - { - self::setFormIdentifier('GENERATE_BOOKING_WASH_CERTIFICATE'); - self::setFormName('Opret vaskecertifikat'); - self::setFormDescription('Du kan oprette et vaskecertifikat til en vask, der allerede er booket. Du skal blot indtaste de nødvendige oplysninger nedenfor.'); - self::setSubmitButtonText('Opret vaskecertifikat'); - // Set the form fields - self::defineInputFieldsAdvanced([ - 'booking_id' => [ - 'description' => 'Booking ID er det unikke ID for den booking, du vil oprette et vaskecertifikat til.', - 'required' => true, - 'placeholder' => 'Indtast booking ID', - 'label' => 'Booking ID', - 'help' => 'Dette er det unikke ID for den booking, du vil oprette et vaskecertifikat til.', - 'error' => 'Du skal indtaste et gyldigt booking ID.', - 'validation_method' => 'validateBookingId', - ], - 'safety_seal' => [ - 'description' => 'En sikkerhedssikring er en form for beskyttelse, der sikrer, at køretøjet ikke er blevet åbnet eller ændret efter vasken.', - 'required' => false, - 'placeholder' => 'Indtast sikkerhedssikring', - 'label' => 'Safety Seal / PLOM', - 'help' => 'Dette er et Safety Seal, der bruges til at dokumentere, at køretøjet er blevet vasket.', - 'error' => 'Du skal indtaste en gyldig sikkerhedssikring.', - 'validation_method' => 'validateInt', - 'default' => '', - ], - 'operator' => [ - 'description' => 'Operatøren er den person, der har vasket køretøjet. Dette felt er valgfrit.', - 'required' => false, - 'placeholder' => 'Indtast operatør', - 'label' => 'Vognvasker', - 'help' => 'Dette er navnet på den person, der har vasket køretøjet.', - 'error' => 'Du skal indtaste en gyldig operatør.', - 'validation_method' => 'validateString', - 'default' => '', - ], - ]); - - } -} \ No newline at end of file diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php b/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php index 5fde45b2..dc18db0c 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_condition_evaluator.php @@ -14,6 +14,14 @@ use modules\selfserve\interfaces\selfserve_condition_evaluator_i; class selfserve_condition_evaluator implements selfserve_condition_evaluator_i { + private const V2_OPERATORS = [ + 'IS_TRUE', + 'IS_FALSE', + 'IS_SET', + 'IS_TRUE_OR_NOT_SET', + 'IS_FALSE_OR_NOT_SET', + ]; + public function evaluate(array $conditions, array $rules, array $answers): array { $rulesByConditionId = []; @@ -89,6 +97,84 @@ class selfserve_condition_evaluator implements selfserve_condition_evaluator_i return $results; } + public function evaluateExpressions(array $conditions, array $answers): array + { + return $this->evaluateExpressionsWithTrace($conditions, $answers)['results']; + } + + public function evaluateExpressionsWithTrace(array $conditions, array $answers): array + { + $conditionsById = []; + foreach ($conditions as $condition) { + $conditionId = (int)($condition['id'] ?? 0); + if ($conditionId > 0) { + $conditionsById[$conditionId] = $condition; + } + } + + $results = []; + $trace = []; + $resolving = []; + + $resolver = function (int $conditionId) use (&$resolver, &$results, &$trace, &$resolving, $conditionsById, $answers): bool { + if (array_key_exists($conditionId, $results)) { + return $results[$conditionId]; + } + if (isset($resolving[$conditionId])) { + $results[$conditionId] = false; + $trace[$conditionId] = [ + 'type' => 'cycle', + 'condition_id' => $conditionId, + 'result' => false, + 'reason' => 'Condition dependency cycle detected.', + ]; + return false; + } + + $condition = $conditionsById[$conditionId] ?? null; + if (!is_array($condition)) { + $results[$conditionId] = false; + $trace[$conditionId] = [ + 'type' => 'missing_condition', + 'condition_id' => $conditionId, + 'result' => false, + 'reason' => 'Condition was not found.', + ]; + return false; + } + + $expression = is_array($condition['expression'] ?? null) + ? (array)$condition['expression'] + : $this->emptyExpression(); + + $resolving[$conditionId] = true; + $evaluated = $this->evaluateExpressionNode($expression, $answers, $resolver); + unset($resolving[$conditionId]); + + $results[$conditionId] = (bool)($evaluated['result'] ?? false); + $previousTrace = $trace[$conditionId] ?? null; + $trace[$conditionId] = [ + 'type' => 'condition', + 'condition_id' => $conditionId, + 'result' => $results[$conditionId], + 'expression' => $evaluated, + ]; + if (is_array($previousTrace) && ($previousTrace['type'] ?? null) === 'cycle') { + $trace[$conditionId]['cycle'] = $previousTrace; + } + return $results[$conditionId]; + }; + + foreach (array_keys($conditionsById) as $conditionId) { + $resolver((int)$conditionId); + } + + return [ + 'results' => $results, + 'trace' => $trace, + ]; + } + public function taskGateSatisfied(?int $gateId, array $conditionResults, array $answers): bool { if ($gateId === null || $gateId <= 0) { @@ -147,4 +233,360 @@ class selfserve_condition_evaluator implements selfserve_condition_evaluator_i selfserve_condition_rule_type::IS_FALSE_OR_NOT_SET => $value === false || $value === null, }; } + + /** + * @param array $node + * @param array $answers + * @param callable(int):bool $conditionResolver + * @return array + */ + private function evaluateExpressionNode(array $node, array $answers, callable $conditionResolver): array + { + $type = strtolower((string)($node['type'] ?? $node['kind'] ?? 'group')); + if ($type === 'predicate') { + return $this->evaluateExpressionPredicate($node, $answers, $conditionResolver); + } + if (in_array($type, ['branch', 'if', 'if_else'], true)) { + return $this->evaluateBranchExpression($node, $answers, $conditionResolver); + } + if ($type === 'case') { + return $this->evaluateCaseExpression($node, $answers, $conditionResolver); + } + + $operator = strtoupper((string)($node['operator'] ?? $node['mode'] ?? 'ALL')); + if (!in_array($operator, ['ALL', 'ANY'], true)) { + $operator = 'ALL'; + } + + $children = is_array($node['children'] ?? null) ? array_values((array)$node['children']) : []; + if ($children === []) { + return [ + 'type' => 'group', + 'operator' => $operator, + 'result' => false, + 'children' => [], + 'reason' => 'Group has no predicates.', + ]; + } + + $childTraces = []; + foreach ($children as $child) { + if (!is_array($child)) { + continue; + } + $childTraces[] = $this->evaluateExpressionNode((array)$child, $answers, $conditionResolver); + } + + if ($childTraces === []) { + $result = false; + } elseif ($operator === 'ANY') { + $result = count(array_filter($childTraces, static fn(array $child): bool => ($child['result'] ?? false) === true)) > 0; + } else { + $result = count(array_filter($childTraces, static fn(array $child): bool => ($child['result'] ?? false) !== true)) === 0; + } + + return [ + 'type' => 'group', + 'operator' => $operator, + 'result' => $result, + 'children' => $childTraces, + 'reason' => $result ? 'Group passed.' : 'Group did not pass.', + ]; + } + + /** + * @param array $node + * @param array $answers + * @param callable(int):bool $conditionResolver + * @return array + */ + private function evaluateExpressionPredicate(array $node, array $answers, callable $conditionResolver): array + { + $subjectType = strtolower((string)($node['subject_type'] ?? $node['object_type'] ?? '')); + $subjectId = (int)($node['subject_id'] ?? $node['object_id'] ?? 0); + $operator = strtoupper((string)($node['operator'] ?? $node['rule_type'] ?? '')); + + if (!in_array($operator, self::V2_OPERATORS, true)) { + return [ + 'type' => 'predicate', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'operator' => $operator, + 'actual_value' => null, + 'result' => false, + 'reason' => 'Unsupported predicate operator.', + ]; + } + + $actual = $this->expressionSubjectValue($subjectType, $subjectId, $answers, $conditionResolver); + $result = match ($operator) { + 'IS_TRUE' => $actual === true, + 'IS_FALSE' => $actual === false, + 'IS_SET' => $actual !== null, + 'IS_TRUE_OR_NOT_SET' => $actual === true || $actual === null, + 'IS_FALSE_OR_NOT_SET' => $actual === false || $actual === null, + default => false, + }; + + return [ + 'type' => 'predicate', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'operator' => $operator, + 'actual_value' => $actual, + 'result' => $result, + 'reason' => $result ? 'Predicate passed.' : 'Predicate did not pass.', + ]; + } + + /** + * @param array $node + * @param array $answers + * @param callable(int):bool $conditionResolver + * @return array + */ + private function evaluateBranchExpression(array $node, array $answers, callable $conditionResolver): array + { + $branches = is_array($node['branches'] ?? null) ? array_values((array)$node['branches']) : []; + if ($branches === []) { + return [ + 'type' => 'branch', + 'operator' => 'IF_ELSE', + 'result' => false, + 'branches' => [], + 'reason' => 'Branch has no clauses.', + ]; + } + + $branchTraces = []; + foreach ($branches as $index => $branch) { + if (!is_array($branch)) { + $branchTraces[] = [ + 'index' => $index, + 'kind' => 'invalid', + 'matched' => false, + 'result' => false, + 'reason' => 'Branch clause is invalid.', + ]; + continue; + } + + $kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if'))); + $isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true); + $whenTrace = null; + $matched = $isElse; + if (!$isElse) { + $when = is_array($branch['when'] ?? null) ? (array)$branch['when'] : $this->emptyExpression(); + $whenTrace = $this->evaluateExpressionNode($when, $answers, $conditionResolver); + $matched = (bool)($whenTrace['result'] ?? false); + } + + if (!$matched) { + $branchTraces[] = [ + 'index' => $index, + 'kind' => $isElse ? 'else' : $kind, + 'matched' => false, + 'result' => false, + 'when' => $whenTrace, + 'reason' => $isElse ? 'Else branch was not reached.' : 'Branch condition did not pass.', + ]; + continue; + } + + $then = is_array($branch['then'] ?? null) + ? (array)$branch['then'] + : (is_array($branch['result_expression'] ?? null) ? (array)$branch['result_expression'] : $this->emptyExpression()); + $thenTrace = $this->evaluateExpressionNode($then, $answers, $conditionResolver); + $result = (bool)($thenTrace['result'] ?? false); + $branchTraces[] = [ + 'index' => $index, + 'kind' => $isElse ? 'else' : $kind, + 'matched' => true, + 'result' => $result, + 'when' => $whenTrace, + 'then' => $thenTrace, + 'reason' => $result ? 'Branch matched and passed.' : 'Branch matched and did not pass.', + ]; + + return [ + 'type' => 'branch', + 'operator' => 'IF_ELSE', + 'result' => $result, + 'selected_index' => $index, + 'branches' => $branchTraces, + 'reason' => $result ? 'Selected branch passed.' : 'Selected branch did not pass.', + ]; + } + + if (is_array($node['default'] ?? null)) { + $defaultTrace = $this->evaluateExpressionNode((array)$node['default'], $answers, $conditionResolver); + $result = (bool)($defaultTrace['result'] ?? false); + return [ + 'type' => 'branch', + 'operator' => 'IF_ELSE', + 'result' => $result, + 'branches' => $branchTraces, + 'default' => $defaultTrace, + 'reason' => $result ? 'Default branch passed.' : 'Default branch did not pass.', + ]; + } + + return [ + 'type' => 'branch', + 'operator' => 'IF_ELSE', + 'result' => false, + 'branches' => $branchTraces, + 'reason' => 'No branch matched.', + ]; + } + + /** + * @param array $node + * @param array $answers + * @param callable(int):bool $conditionResolver + * @return array + */ + private function evaluateCaseExpression(array $node, array $answers, callable $conditionResolver): array + { + $subjectType = strtolower((string)($node['subject_type'] ?? $node['object_type'] ?? '')); + $subjectId = (int)($node['subject_id'] ?? $node['object_id'] ?? 0); + $actual = $this->expressionSubjectValue($subjectType, $subjectId, $answers, $conditionResolver); + $cases = is_array($node['cases'] ?? null) ? array_values((array)$node['cases']) : []; + + if (!in_array($subjectType, ['question', 'condition'], true) || $subjectId <= 0) { + return [ + 'type' => 'case', + 'operator' => 'CASE', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'actual_value' => null, + 'result' => false, + 'cases' => [], + 'reason' => 'Case subject is invalid.', + ]; + } + + $caseTraces = []; + foreach ($cases as $index => $case) { + if (!is_array($case)) { + $caseTraces[] = [ + 'index' => $index, + 'matched' => false, + 'result' => false, + 'reason' => 'Case clause is invalid.', + ]; + continue; + } + + $expected = $case['value'] ?? null; + $matched = $this->caseValueMatches($expected, $actual); + if (!$matched) { + $caseTraces[] = [ + 'index' => $index, + 'value' => $expected, + 'matched' => false, + 'result' => false, + 'reason' => 'Case value did not match.', + ]; + continue; + } + + $then = is_array($case['then'] ?? null) + ? (array)$case['then'] + : (is_array($case['result_expression'] ?? null) ? (array)$case['result_expression'] : $this->emptyExpression()); + $thenTrace = $this->evaluateExpressionNode($then, $answers, $conditionResolver); + $result = (bool)($thenTrace['result'] ?? false); + $caseTraces[] = [ + 'index' => $index, + 'value' => $expected, + 'matched' => true, + 'result' => $result, + 'then' => $thenTrace, + 'reason' => $result ? 'Case matched and passed.' : 'Case matched and did not pass.', + ]; + + return [ + 'type' => 'case', + 'operator' => 'CASE', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'actual_value' => $actual, + 'result' => $result, + 'selected_index' => $index, + 'cases' => $caseTraces, + 'reason' => $result ? 'Selected case passed.' : 'Selected case did not pass.', + ]; + } + + if (is_array($node['default'] ?? null)) { + $defaultTrace = $this->evaluateExpressionNode((array)$node['default'], $answers, $conditionResolver); + $result = (bool)($defaultTrace['result'] ?? false); + return [ + 'type' => 'case', + 'operator' => 'CASE', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'actual_value' => $actual, + 'result' => $result, + 'cases' => $caseTraces, + 'default' => $defaultTrace, + 'reason' => $result ? 'Default case passed.' : 'Default case did not pass.', + ]; + } + + return [ + 'type' => 'case', + 'operator' => 'CASE', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'actual_value' => $actual, + 'result' => false, + 'cases' => $caseTraces, + 'reason' => 'No case matched.', + ]; + } + + /** + * @param array $answers + * @param callable(int):bool $conditionResolver + */ + private function expressionSubjectValue(string $subjectType, int $subjectId, array $answers, callable $conditionResolver): ?bool + { + if ($subjectType === 'condition') { + return $subjectId > 0 ? $conditionResolver($subjectId) : null; + } + if ($subjectType === 'question') { + return $answers[$subjectId] ?? null; + } + return null; + } + + private function caseValueMatches(mixed $expected, ?bool $actual): bool + { + if (is_string($expected)) { + $normalized = strtolower(trim($expected)); + return match ($normalized) { + 'true', '1', 'yes' => $actual === true, + 'false', '0', 'no' => $actual === false, + 'null', 'unset', 'not_set', 'unanswered' => $actual === null, + 'set' => $actual !== null, + 'any', '*' => true, + default => false, + }; + } + + return $expected === $actual; + } + + /** + * @return array + */ + private function emptyExpression(): array + { + return [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [], + ]; + } } diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php b/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php index 47d343ef..ae3977a8 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php @@ -10,9 +10,11 @@ require_once WD . '/objects/department_selfserve_questions_o.php'; require_once WD . '/objects/department_selfserve_tasks_o.php'; require_once WD . '/objects/selfserve_config_versions_o.php'; require_once WD . '/modules/selfserve/helpers/selfserve_task_gate_type.php'; +require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php'; use classes\selfserve_schema_bootstrap; use modules\selfserve\helpers\selfserve_task_gate_type; +use modules\selfserve\classes\selfserve_studio_actions; use objects\departments_o; use objects\department_selfserve_condition_rules_o; use objects\department_selfserve_conditions_o; @@ -25,6 +27,15 @@ class selfserve_config_versioning public const STATUS_DRAFT = 'DRAFT'; public const STATUS_PUBLISHED = 'PUBLISHED'; public const STATUS_ARCHIVED = 'ARCHIVED'; + public const SCHEMA_VERSION_V2 = 2; + + private const V2_PREDICATE_OPERATORS = [ + 'IS_TRUE', + 'IS_FALSE', + 'IS_SET', + 'IS_TRUE_OR_NOT_SET', + 'IS_FALSE_OR_NOT_SET', + ]; public function __construct() { @@ -47,6 +58,25 @@ class selfserve_config_versioning ]; } + /** + * @return array{version_id:int,config:array}|null + */ + public function getPublishedV2Config(int $departmentId): ?array + { + $published = $this->getPublishedConfig($departmentId); + if (!is_array($published) || !$this->isV2Config((array)($published['config'] ?? []))) { + return null; + } + + $published['config'] = $this->normalizeV2Config((array)$published['config']); + return $published; + } + + public function isV2Config(array $config): bool + { + return (int)($config['schema_version'] ?? 0) === self::SCHEMA_VERSION_V2; + } + /** * @return array */ @@ -57,9 +87,17 @@ class selfserve_config_versioning $validation = $this->validateConfig($config); if ($versionObject->exists()) { - if ($forceRefresh) { - $versionObject->config_json->set($config); - $versionObject->validation_result_json->set($validation); + $existingConfig = (array)($versionObject->config_json->value() ?? []); + if ($forceRefresh || !$this->isV2Config($existingConfig)) { + $nextConfig = $forceRefresh ? $config : $this->migrateLegacyConfigToV2($existingConfig + ['department_id' => $departmentId]); + $versionObject->config_json->set($nextConfig); + $versionObject->validation_result_json->set($this->validateConfig($nextConfig)); + } else { + $normalizedConfig = $this->normalizeV2Config($existingConfig + ['department_id' => $departmentId]); + if ($normalizedConfig !== $existingConfig) { + $versionObject->config_json->set($normalizedConfig); + $versionObject->validation_result_json->set($this->validateConfig($normalizedConfig)); + } } return $versionObject->asArray(); } @@ -117,7 +155,13 @@ class selfserve_config_versioning $draft = (new selfserve_config_versions_o())->select((int)$created['id']); } - $validation = $this->validateConfig((array)($draft->config_json->value() ?? [])); + $config = (array)($draft->config_json->value() ?? []); + if (!$this->isV2Config($config)) { + $config = $this->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]); + $draft->config_json->set($config); + } + + $validation = $this->validateConfig($config); $draft->validation_result_json->set($validation); if (($validation['valid'] ?? false) !== true) { throw new \RuntimeException('Draft validation failed. Resolve errors before publishing.'); @@ -132,7 +176,7 @@ class selfserve_config_versioning // Keep editing path open by creating a new draft cloned from newly published version. $publishedArray = $draft->asArray(); - $this->createDraftFromConfig($departmentId, (array)$draft->config_json->value(), (int)$draft->id, $publishedBy); + $this->createDraftFromConfig($departmentId, $config, (int)$draft->id, $publishedBy); return $publishedArray; } @@ -148,6 +192,9 @@ class selfserve_config_versioning } $config = (array)($target->config_json->value() ?? []); + if (!$this->isV2Config($config)) { + $config = $this->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]); + } $validation = $this->validateConfig($config); if (($validation['valid'] ?? false) !== true) { throw new \RuntimeException('Target version cannot be rolled back because validation fails.'); @@ -187,6 +234,14 @@ class selfserve_config_versioning * @return array */ public function snapshotLegacyConfig(int $departmentId): array + { + return $this->migrateLegacyConfigToV2($this->snapshotLegacyTableConfig($departmentId)); + } + + /** + * @return array + */ + protected function snapshotLegacyTableConfig(int $departmentId): array { $questionsObject = new department_selfserve_questions_o(); $conditionsObject = new department_selfserve_conditions_o(); @@ -218,17 +273,8 @@ class selfserve_config_versioning 'deleted_at' => null, ], ['id', 'condition_id', 'type', 'object_type', 'object_id', 'name', 'description']); - $tasks = array_map(function (array $task): array { - $gateType = selfserve_task_gate_type::tryFrom((string)($task['gate_type'] ?? '')); - if ($gateType === null) { - $legacyGateId = $this->nullableInt($task['condition_id'] ?? null); - $task['gate_type'] = $legacyGateId === null - ? selfserve_task_gate_type::ALWAYS->value - : selfserve_task_gate_type::CONDITION->value; - $task['gate_ref_id'] = $legacyGateId; - } - return $task; - }, $tasks); + $conditionIdMap = array_fill_keys($conditionIds, true); + $tasks = array_map(fn(array $task): array => $this->normalizeTaskGate($task, $conditionIdMap), $tasks); usort($questions, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']); usort($conditions, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']); @@ -241,6 +287,7 @@ class selfserve_config_versioning 'conditions' => $conditions, 'rules' => $rules, 'tasks' => $tasks, + 'actions' => [], 'snapshot_meta' => [ 'captured_at' => date('c'), 'source' => 'legacy_tables', @@ -248,12 +295,69 @@ class selfserve_config_versioning ]; } + /** + * @param array $legacyConfig + * @return array + */ + public function migrateLegacyConfigToV2(array $legacyConfig): array + { + if ($this->isV2Config($legacyConfig)) { + return $this->normalizeV2Config($legacyConfig); + } + + $rulesByCondition = []; + foreach ((array)($legacyConfig['rules'] ?? []) as $rule) { + if (!is_array($rule)) { + continue; + } + $rulesByCondition[(int)($rule['condition_id'] ?? 0)][] = $rule; + } + + $migrationIssues = []; + $conditions = []; + foreach ((array)($legacyConfig['conditions'] ?? []) as $condition) { + if (!is_array($condition)) { + continue; + } + $conditionId = (int)($condition['id'] ?? 0); + $conditionRules = array_values((array)($rulesByCondition[$conditionId] ?? [])); + $condition['expression'] = $this->migrateLegacyRulesToExpression($conditionId, $conditionRules, $migrationIssues); + $conditions[] = $condition; + } + + $config = [ + 'schema_version' => self::SCHEMA_VERSION_V2, + 'department_id' => (int)($legacyConfig['department_id'] ?? 0), + 'questions' => array_values((array)($legacyConfig['questions'] ?? [])), + 'conditions' => array_values($conditions), + 'rules' => [], + 'tasks' => array_values((array)($legacyConfig['tasks'] ?? [])), + 'actions' => array_values((array)($legacyConfig['actions'] ?? [])), + 'v2_meta' => [ + 'migrated_from' => (int)($legacyConfig['schema_version'] ?? 1), + 'migrated_at' => date('c'), + 'source' => (string)($legacyConfig['snapshot_meta']['source'] ?? 'legacy_config'), + 'next_ids' => $this->nextIdsForConfig($legacyConfig), + ], + ]; + + if ($migrationIssues !== []) { + $config['migration_issues'] = $migrationIssues; + } + + return $this->normalizeV2Config($config); + } + /** * @param array $config * @return array */ public function validateConfig(array $config): array { + if ($this->isV2Config($config)) { + return $this->validateV2Config($this->normalizeV2Config($config)); + } + $errors = []; $warnings = []; @@ -261,6 +365,7 @@ class selfserve_config_versioning $conditions = is_array($config['conditions'] ?? null) ? $config['conditions'] : []; $rules = is_array($config['rules'] ?? null) ? $config['rules'] : []; $tasks = is_array($config['tasks'] ?? null) ? $config['tasks'] : []; + $actions = is_array($config['actions'] ?? null) ? $config['actions'] : []; $questionIds = []; foreach ($questions as $question) { @@ -273,6 +378,7 @@ class selfserve_config_versioning } $conditionIds = []; + $conditionParents = []; foreach ($conditions as $condition) { $id = (int)($condition['id'] ?? 0); if ($id <= 0) { @@ -281,9 +387,20 @@ class selfserve_config_versioning } $conditionIds[$id] = true; $parentId = $this->nullableInt($condition['condition_id'] ?? null); + $conditionParents[$id] = $parentId; + } + + foreach ($conditionParents as $id => $parentId) { if ($parentId !== null && !isset($conditionIds[$parentId])) { - $warnings[] = 'Condition ' . $id . ' references parent condition ' . $parentId . ' that may be defined later or missing.'; + $errors[] = 'Condition ' . $id . ' references unknown parent condition_id ' . $parentId; } + if ($parentId === $id) { + $errors[] = 'Condition ' . $id . ' cannot reference itself as parent condition.'; + } + } + + foreach ($this->detectConditionCycles($conditionParents) as $cycle) { + $errors[] = 'Condition cycle detected: ' . implode(' -> ', $cycle); } foreach ($rules as $rule) { @@ -302,14 +419,14 @@ class selfserve_config_versioning } foreach ($tasks as $task) { + $resolvedGate = $this->resolveTaskGate($task, $conditionIds); $gateTypeRaw = (string)($task['gate_type'] ?? ''); - $gateType = selfserve_task_gate_type::tryFrom($gateTypeRaw); - if ($gateType === null) { + $gateType = $resolvedGate['gate_type']; + if (selfserve_task_gate_type::tryFrom($gateTypeRaw) === null && $gateTypeRaw !== '') { $warnings[] = 'Task ' . (int)($task['id'] ?? 0) . ' has invalid gate_type `' . $gateTypeRaw . '`, falling back to legacy handling.'; - continue; } - $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); + $gateRefId = $resolvedGate['gate_ref_id']; if ($gateType === selfserve_task_gate_type::ALWAYS) { continue; } @@ -334,16 +451,404 @@ class selfserve_config_versioning 'conditions' => count($conditions), 'rules' => count($rules), 'tasks' => count($tasks), + 'actions' => count($actions), ], 'validated_at' => date('c'), ]; } + /** + * @param array $config + * @return array + */ + protected function validateV2Config(array $config): array + { + $errors = []; + $warnings = []; + + foreach ((array)($config['migration_issues'] ?? []) as $issue) { + if (is_array($issue)) { + $errors[] = (string)($issue['message'] ?? 'Migration issue detected.'); + } else { + $errors[] = (string)$issue; + } + } + + $questions = is_array($config['questions'] ?? null) ? array_values((array)$config['questions']) : []; + $conditions = is_array($config['conditions'] ?? null) ? array_values((array)$config['conditions']) : []; + $tasks = is_array($config['tasks'] ?? null) ? array_values((array)$config['tasks']) : []; + $actions = is_array($config['actions'] ?? null) ? array_values((array)$config['actions']) : []; + + $questionIds = []; + foreach ($questions as $question) { + $id = (int)($question['id'] ?? 0); + if ($id <= 0) { + $errors[] = 'Question without valid id.'; + continue; + } + $questionIds[$id] = true; + } + + $conditionIds = []; + $conditionParents = []; + foreach ($conditions as $condition) { + $id = (int)($condition['id'] ?? 0); + if ($id <= 0) { + $errors[] = 'Condition without valid id.'; + continue; + } + $conditionIds[$id] = true; + $conditionParents[$id] = $this->nullableInt($condition['condition_id'] ?? null); + } + + $usedConditionIds = []; + $conditionEdges = []; + $conditionHasPredicate = []; + foreach ($conditions as $condition) { + $conditionId = (int)($condition['id'] ?? 0); + if ($conditionId <= 0) { + continue; + } + $parentId = $conditionParents[$conditionId] ?? null; + if ($parentId !== null) { + if (!isset($conditionIds[$parentId])) { + $errors[] = 'Condition ' . $conditionId . ' references unknown parent condition_id ' . $parentId; + } + if ($parentId === $conditionId) { + $errors[] = 'Condition ' . $conditionId . ' cannot reference itself as parent condition.'; + } + $conditionEdges[$conditionId][] = $parentId; + } + + $expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : $this->emptyV2Expression(); + $expressionValidation = $this->validateExpressionNode( + $expression, + $conditionId, + $questionIds, + $conditionIds, + $usedConditionIds, + $conditionEdges, + ); + $conditionHasPredicate[$conditionId] = $expressionValidation['has_predicate']; + foreach ($expressionValidation['errors'] as $message) { + $errors[] = $message; + } + } + + foreach ($questions as $question) { + $conditionId = $this->nullableInt($question['condition_id'] ?? null); + if ($conditionId === null) { + continue; + } + $usedConditionIds[$conditionId] = true; + if (!isset($conditionIds[$conditionId])) { + $errors[] = 'Question ' . (int)($question['id'] ?? 0) . ' references unknown visibility condition_id ' . $conditionId; + } + } + + foreach ($tasks as $task) { + $taskId = (int)($task['id'] ?? 0); + $gateTypeRaw = strtoupper((string)($task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value)); + $gateType = selfserve_task_gate_type::tryFrom($gateTypeRaw); + if ($gateType === null) { + $errors[] = 'Task ' . $taskId . ' has invalid gate_type `' . $gateTypeRaw . '`.'; + continue; + } + + $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); + if ($gateType === selfserve_task_gate_type::ALWAYS) { + continue; + } + if ($gateRefId === null) { + $errors[] = 'Task ' . $taskId . ' requires gate_ref_id for gate_type ' . $gateType->value; + continue; + } + if ($gateType === selfserve_task_gate_type::CONDITION) { + $usedConditionIds[$gateRefId] = true; + if (!isset($conditionIds[$gateRefId])) { + $errors[] = 'Task ' . $taskId . ' references unknown condition gate_ref_id ' . $gateRefId; + } + } + if ($gateType === selfserve_task_gate_type::QUESTION && !isset($questionIds[$gateRefId])) { + $errors[] = 'Task ' . $taskId . ' references unknown question gate_ref_id ' . $gateRefId; + } + } + + $actionIds = []; + foreach ($actions as $action) { + if (!is_array($action)) { + $errors[] = 'Action has invalid payload.'; + continue; + } + $actionId = (int)($action['id'] ?? 0); + if ($actionId <= 0) { + $errors[] = 'Action without valid id.'; + continue; + } + if (isset($actionIds[$actionId])) { + $errors[] = 'Duplicate action id ' . $actionId . '.'; + } + $actionIds[$actionId] = true; + + $event = strtolower(trim((string)($action['event'] ?? ''))); + if (!in_array($event, selfserve_studio_actions::events(), true)) { + $errors[] = 'Action ' . $actionId . ' has invalid event `' . $event . '`.'; + } + + $washMode = strtolower(trim((string)($action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH))); + if (!in_array($washMode, selfserve_studio_actions::washModes(), true)) { + $errors[] = 'Action ' . $actionId . ' has invalid wash_mode `' . $washMode . '`.'; + } + if ($event === selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED && $washMode === selfserve_studio_actions::MODE_MANUAL) { + $warnings[] = 'Action ' . $actionId . ' uses manual mode for the machine-start event and will never run.'; + } + + $operation = strtolower(trim((string)($action['operation'] ?? ''))); + if (!in_array($operation, selfserve_studio_actions::operations(), true)) { + $errors[] = 'Action ' . $actionId . ' has invalid operation `' . $operation . '`.'; + } + if (selfserve_studio_actions::isRelayOperation($operation) && !array_key_exists('relay_state', $action)) { + $errors[] = 'Action ' . $actionId . ' requires relay_state for operation ' . $operation . '.'; + } + + $options = is_array($action['options'] ?? null) ? (array)$action['options'] : []; + $failurePolicy = strtolower(trim((string)($options['failure_policy'] ?? selfserve_studio_actions::FAILURE_CONTINUE))); + if (!in_array($failurePolicy, selfserve_studio_actions::failurePolicies(), true)) { + $errors[] = 'Action ' . $actionId . ' has invalid failure_policy `' . $failurePolicy . '`.'; + } + + $conditionId = $this->nullableInt($action['condition_id'] ?? null); + if ($conditionId !== null) { + $usedConditionIds[$conditionId] = true; + if (!isset($conditionIds[$conditionId])) { + $errors[] = 'Action ' . $actionId . ' references unknown condition_id ' . $conditionId . '.'; + } + } + } + + foreach ($usedConditionIds as $conditionId => $_used) { + if (isset($conditionIds[(int)$conditionId]) && (($conditionHasPredicate[(int)$conditionId] ?? false) !== true)) { + $errors[] = 'Condition ' . (int)$conditionId . ' is used but has an empty expression.'; + } + } + + foreach ($this->detectDirectedConditionCycles($conditionEdges) as $cycle) { + $errors[] = 'Condition cycle detected: ' . implode(' -> ', $cycle); + } + + return [ + 'valid' => $errors === [], + 'errors' => array_values(array_unique($errors)), + 'warnings' => $warnings, + 'stats' => [ + 'schema_version' => self::SCHEMA_VERSION_V2, + 'questions' => count($questions), + 'conditions' => count($conditions), + 'rules' => 0, + 'tasks' => count($tasks), + 'actions' => count($actions), + ], + 'validated_at' => date('c'), + ]; + } + + /** + * @param array $expression + * @param array $questionIds + * @param array $conditionIds + * @param array $usedConditionIds + * @param array> $conditionEdges + * @return array{errors:array,has_predicate:bool} + */ + protected function validateExpressionNode(array $expression, int $ownerConditionId, array $questionIds, array $conditionIds, array &$usedConditionIds, array &$conditionEdges): array + { + $errors = []; + $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group')); + if ($type === 'predicate') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + $operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? '')); + if (!in_array($operator, self::V2_PREDICATE_OPERATORS, true)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has invalid predicate operator `' . $operator . '`.'; + } + if ($subjectType === 'question') { + if ($subjectId <= 0 || !isset($questionIds[$subjectId])) { + $errors[] = 'Condition ' . $ownerConditionId . ' references unknown question predicate subject_id ' . $subjectId; + } + } elseif ($subjectType === 'condition') { + $usedConditionIds[$subjectId] = true; + $conditionEdges[$ownerConditionId][] = $subjectId; + if ($subjectId === $ownerConditionId) { + $errors[] = 'Condition ' . $ownerConditionId . ' cannot reference itself in an expression.'; + } + if ($subjectId <= 0 || !isset($conditionIds[$subjectId])) { + $errors[] = 'Condition ' . $ownerConditionId . ' references unknown condition predicate subject_id ' . $subjectId; + } + } else { + $errors[] = 'Condition ' . $ownerConditionId . ' has unsupported predicate subject_type `' . $subjectType . '`.'; + } + + return [ + 'errors' => $errors, + 'has_predicate' => true, + ]; + } + + if (in_array($type, ['branch', 'if', 'if_else'], true)) { + $branches = is_array($expression['branches'] ?? null) ? array_values((array)$expression['branches']) : []; + if ($branches === []) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an empty if/else expression.'; + } + + $hasPredicate = false; + foreach ($branches as $index => $branch) { + if (!is_array($branch)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid if/else clause.'; + continue; + } + + $kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if'))); + $isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true); + if (!$isElse) { + if (!is_array($branch['when'] ?? null)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an if/else clause without a when expression.'; + } else { + $whenValidation = $this->validateExpressionNode((array)$branch['when'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges); + $hasPredicate = $hasPredicate || $whenValidation['has_predicate']; + foreach ($whenValidation['errors'] as $message) { + $errors[] = $message; + } + } + } + + if (!is_array($branch['then'] ?? null)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an if/else clause without a then expression.'; + continue; + } + + $thenValidation = $this->validateExpressionNode((array)$branch['then'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges); + $hasPredicate = $hasPredicate || $thenValidation['has_predicate']; + foreach ($thenValidation['errors'] as $message) { + $errors[] = $message; + } + } + + if (array_key_exists('default', $expression)) { + if (!is_array($expression['default'])) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid if/else default expression.'; + } else { + $defaultValidation = $this->validateExpressionNode((array)$expression['default'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges); + $hasPredicate = $hasPredicate || $defaultValidation['has_predicate']; + foreach ($defaultValidation['errors'] as $message) { + $errors[] = $message; + } + } + } + + return [ + 'errors' => $errors, + 'has_predicate' => $hasPredicate, + ]; + } + + if ($type === 'case') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + if ($subjectType === 'question') { + if ($subjectId <= 0 || !isset($questionIds[$subjectId])) { + $errors[] = 'Condition ' . $ownerConditionId . ' references unknown question case subject_id ' . $subjectId; + } + } elseif ($subjectType === 'condition') { + $usedConditionIds[$subjectId] = true; + $conditionEdges[$ownerConditionId][] = $subjectId; + if ($subjectId === $ownerConditionId) { + $errors[] = 'Condition ' . $ownerConditionId . ' cannot reference itself in a case expression.'; + } + if ($subjectId <= 0 || !isset($conditionIds[$subjectId])) { + $errors[] = 'Condition ' . $ownerConditionId . ' references unknown condition case subject_id ' . $subjectId; + } + } else { + $errors[] = 'Condition ' . $ownerConditionId . ' has unsupported case subject_type `' . $subjectType . '`.'; + } + + $cases = is_array($expression['cases'] ?? null) ? array_values((array)$expression['cases']) : []; + if ($cases === []) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an empty case expression.'; + } + + $hasPredicate = false; + foreach ($cases as $case) { + if (!is_array($case)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid case clause.'; + continue; + } + if (!array_key_exists('value', $case)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has a case clause without a value.'; + } + if (!is_array($case['then'] ?? null)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has a case clause without a then expression.'; + continue; + } + + $thenValidation = $this->validateExpressionNode((array)$case['then'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges); + $hasPredicate = $hasPredicate || $thenValidation['has_predicate']; + foreach ($thenValidation['errors'] as $message) { + $errors[] = $message; + } + } + + if (array_key_exists('default', $expression)) { + if (!is_array($expression['default'])) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid case default expression.'; + } else { + $defaultValidation = $this->validateExpressionNode((array)$expression['default'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges); + $hasPredicate = $hasPredicate || $defaultValidation['has_predicate']; + foreach ($defaultValidation['errors'] as $message) { + $errors[] = $message; + } + } + } + + return [ + 'errors' => $errors, + 'has_predicate' => $hasPredicate, + ]; + } + + $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL')); + if (!in_array($operator, ['ALL', 'ANY'], true)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has invalid group operator `' . $operator . '`.'; + } + + $children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : []; + $hasPredicate = false; + foreach ($children as $child) { + if (!is_array($child)) { + $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid expression child.'; + continue; + } + $childValidation = $this->validateExpressionNode((array)$child, $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges); + $hasPredicate = $hasPredicate || $childValidation['has_predicate']; + foreach ($childValidation['errors'] as $message) { + $errors[] = $message; + } + } + + return [ + 'errors' => $errors, + 'has_predicate' => $hasPredicate, + ]; + } + protected function createDraftFromConfig(int $departmentId, array $config, ?int $sourceVersionId, ?int $createdBy): void { // Remove stale drafts first. $this->deleteAllDrafts($departmentId); + if (!$this->isV2Config($config)) { + $config = $this->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]); + } + $validation = $this->validateConfig($config); $latestVersionNumber = $this->getLatestVersionNumber($departmentId); (new selfserve_config_versions_o())->add( @@ -421,4 +926,305 @@ class selfserve_config_versioning $intValue = (int)$value; return $intValue <= 0 ? null : $intValue; } + + /** + * @param array $parents + * @return array> + */ + protected function detectConditionCycles(array $parents): array + { + $cycles = []; + $seenCycleKeys = []; + + foreach (array_keys($parents) as $startId) { + $path = []; + $indexById = []; + $currentId = (int)$startId; + + while ($currentId > 0 && array_key_exists($currentId, $parents)) { + if (isset($indexById[$currentId])) { + $cycle = array_slice($path, $indexById[$currentId]); + $cycle[] = $currentId; + $keyNodes = $cycle; + sort($keyNodes); + $key = implode(':', $keyNodes); + if (!isset($seenCycleKeys[$key])) { + $seenCycleKeys[$key] = true; + $cycles[] = $cycle; + } + break; + } + + $indexById[$currentId] = count($path); + $path[] = $currentId; + $currentId = (int)($parents[$currentId] ?? 0); + } + } + + return $cycles; + } + + /** + * @param array> $rules + * @param array> $migrationIssues + * @return array + */ + protected function migrateLegacyRulesToExpression(int $conditionId, array $rules, array &$migrationIssues): array + { + $allChildren = []; + $anyChildren = []; + + foreach ($rules as $rule) { + $predicate = $this->legacyRuleToPredicate($conditionId, $rule, $migrationIssues); + if ($predicate === null) { + continue; + } + + if (strtoupper((string)($rule['type'] ?? '')) === 'IS_TRUE_OR_ANY_TRUE') { + $anyChildren[] = $predicate; + } else { + $allChildren[] = $predicate; + } + } + + if ($anyChildren !== []) { + $allChildren[] = [ + 'type' => 'group', + 'operator' => 'ANY', + 'children' => $anyChildren, + ]; + } + + return [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => $allChildren, + ]; + } + + /** + * @param array $rule + * @param array> $migrationIssues + * @return array|null + */ + protected function legacyRuleToPredicate(int $conditionId, array $rule, array &$migrationIssues): ?array + { + $ruleId = (int)($rule['id'] ?? 0); + $objectType = strtolower((string)($rule['object_type'] ?? '')); + if (!in_array($objectType, ['question', 'condition'], true)) { + $migrationIssues[] = [ + 'severity' => 'error', + 'condition_id' => $conditionId, + 'rule_id' => $ruleId, + 'message' => 'Rule ' . $ruleId . ' uses unsupported object_type `' . $objectType . '` and cannot be migrated to v2.', + ]; + return null; + } + + $legacyType = strtoupper((string)($rule['type'] ?? '')); + $operator = $legacyType === 'IS_TRUE_OR_ANY_TRUE' ? 'IS_TRUE' : $legacyType; + if (!in_array($operator, self::V2_PREDICATE_OPERATORS, true)) { + $migrationIssues[] = [ + 'severity' => 'error', + 'condition_id' => $conditionId, + 'rule_id' => $ruleId, + 'message' => 'Rule ' . $ruleId . ' uses unsupported type `' . $legacyType . '` and cannot be migrated to v2.', + ]; + return null; + } + + return [ + 'type' => 'predicate', + 'subject_type' => $objectType, + 'subject_id' => (int)($rule['object_id'] ?? 0), + 'operator' => $operator, + 'legacy_rule_id' => $ruleId > 0 ? $ruleId : null, + ]; + } + + /** + * @param array $config + * @return array + */ + protected function normalizeV2Config(array $config): array + { + $config['schema_version'] = self::SCHEMA_VERSION_V2; + $config['questions'] = array_values((array)($config['questions'] ?? [])); + $config['conditions'] = array_values(array_map(function ($condition): array { + $condition = is_array($condition) ? $condition : []; + if (!is_array($condition['expression'] ?? null)) { + $condition['expression'] = $this->emptyV2Expression(); + } + return $condition; + }, (array)($config['conditions'] ?? []))); + $config['rules'] = []; + $conditionIds = []; + foreach ($config['conditions'] as $condition) { + $id = (int)($condition['id'] ?? 0); + if ($id > 0) { + $conditionIds[$id] = true; + } + } + $config['tasks'] = array_values(array_map( + fn($task): array => $this->normalizeTaskGate(is_array($task) ? (array)$task : [], $conditionIds), + (array)($config['tasks'] ?? []) + )); + $config['actions'] = array_values(array_map( + static fn($action): array => selfserve_studio_actions::normalize(is_array($action) ? (array)$action : []), + (array)($config['actions'] ?? []) + )); + $config['v2_meta'] = is_array($config['v2_meta'] ?? null) ? (array)$config['v2_meta'] : []; + $config['v2_meta']['next_ids'] = $this->nextIdsForConfig($config); + return $config; + } + + /** + * @param array $task + * @param array $conditionIds + * @return array + */ + protected function normalizeTaskGate(array $task, array $conditionIds): array + { + $resolvedGate = $this->resolveTaskGate($task, $conditionIds); + $task['gate_type'] = $resolvedGate['gate_type']->value; + $task['gate_ref_id'] = $resolvedGate['gate_ref_id']; + + $task['condition_id'] = $resolvedGate['gate_type'] === selfserve_task_gate_type::ALWAYS + ? null + : $resolvedGate['gate_ref_id']; + + return $task; + } + + /** + * @param array $task + * @param array $conditionIds + * @return array{gate_type:selfserve_task_gate_type,gate_ref_id:int|null} + */ + protected function resolveTaskGate(array $task, array $conditionIds): array + { + $gateType = selfserve_task_gate_type::tryFrom(strtoupper(trim((string)($task['gate_type'] ?? '')))); + $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); + $legacyGateId = $this->nullableInt($task['condition_id'] ?? null); + + if ( + $gateType === selfserve_task_gate_type::CONDITION + || $gateType === selfserve_task_gate_type::QUESTION + ) { + return [ + 'gate_type' => $gateType, + 'gate_ref_id' => $gateRefId ?? $legacyGateId, + ]; + } + + $shouldInferLegacyGate = $gateType === null + || ( + $gateType === selfserve_task_gate_type::ALWAYS + && $gateRefId === null + && $legacyGateId !== null + ); + + if ($shouldInferLegacyGate) { + $fallbackGateId = $gateRefId ?? $legacyGateId; + if ($fallbackGateId === null) { + return [ + 'gate_type' => selfserve_task_gate_type::ALWAYS, + 'gate_ref_id' => null, + ]; + } + + return [ + 'gate_type' => $this->containsIntegerId($conditionIds, $fallbackGateId) + ? selfserve_task_gate_type::CONDITION + : selfserve_task_gate_type::QUESTION, + 'gate_ref_id' => $fallbackGateId, + ]; + } + + return [ + 'gate_type' => selfserve_task_gate_type::ALWAYS, + 'gate_ref_id' => null, + ]; + } + + /** + * @param array $ids + */ + protected function containsIntegerId(array $ids, int $id): bool + { + return isset($ids[$id]) || in_array($id, $ids, true); + } + + /** + * @param array $config + * @return array + */ + protected function nextIdsForConfig(array $config): array + { + $next = []; + foreach (['questions' => 'question', 'conditions' => 'condition', 'tasks' => 'task', 'actions' => 'action'] as $key => $name) { + $max = 0; + foreach ((array)($config[$key] ?? []) as $row) { + if (is_array($row)) { + $max = max($max, (int)($row['id'] ?? 0)); + } + } + $next[$name] = $max + 1; + } + return $next; + } + + /** + * @return array + */ + protected function emptyV2Expression(): array + { + return [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [], + ]; + } + + /** + * @param array> $edges + * @return array> + */ + protected function detectDirectedConditionCycles(array $edges): array + { + $cycles = []; + $visiting = []; + $visited = []; + $stack = []; + + $walk = function (int $conditionId) use (&$walk, &$cycles, &$visiting, &$visited, &$stack, $edges): void { + if (isset($visited[$conditionId])) { + return; + } + if (isset($visiting[$conditionId])) { + $start = array_search($conditionId, $stack, true); + $cycle = array_slice($stack, $start === false ? 0 : (int)$start); + $cycle[] = $conditionId; + $cycles[] = $cycle; + return; + } + + $visiting[$conditionId] = true; + $stack[] = $conditionId; + foreach (array_unique(array_map('intval', (array)($edges[$conditionId] ?? []))) as $nextId) { + if ($nextId > 0) { + $walk($nextId); + } + } + array_pop($stack); + unset($visiting[$conditionId]); + $visited[$conditionId] = true; + }; + + foreach (array_keys($edges) as $conditionId) { + $walk((int)$conditionId); + } + + return $cycles; + } } diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_lane_command_arguments.php b/services/nginx/app/modules/selfserve/classes/selfserve_lane_command_arguments.php index 0e41eb93..dd8510f6 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_lane_command_arguments.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_lane_command_arguments.php @@ -6,6 +6,8 @@ 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; /** * Set the license plate of the vehicle currently in the lane @@ -24,6 +26,18 @@ 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; + return $this; + } + public function setParameters($params): self { if (is_array($params)) { @@ -33,7 +47,16 @@ 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'], + FILTER_VALIDATE_BOOLEAN + )); + } } return $this; } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_machine_signal.php b/services/nginx/app/modules/selfserve/classes/selfserve_machine_signal.php new file mode 100644 index 00000000..2e74ae05 --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_machine_signal.php @@ -0,0 +1,427 @@ + $payload + * @return array + */ + public function normalizeShellyPayload(array $payload): array + { + if (isset($payload['events']) && is_array($payload['events'])) { + foreach ((array)$payload['events'] as $eventPayload) { + if (is_array($eventPayload)) { + $payload = array_replace($payload, (array)$eventPayload); + break; + } + } + } + + $event = $this->firstString($payload, ['event', 'event_type', 'eventType', 'name', 'type']); + $component = $this->normalizeComponent($this->firstString($payload, ['component', 'component_id', 'componentId'])); + $relayId = $this->firstString($payload, ['relay_id', 'logical_relay_id', 'logicalRelayId', 'relayId']); + $deviceId = $this->firstString($payload, ['device_id', 'deviceId', 'device']); + $channel = $this->firstInt($payload, ['channel', 'id', 'input_id', 'switch_id']); + $on = $this->extractOnState($payload); + $eventName = strtolower(trim((string)$event)); + + if ($component === null && str_starts_with($eventName, 'input.')) { + $component = 'input'; + } + if ($component === null && str_starts_with($eventName, 'switch.')) { + $component = 'switch'; + } + + $positiveEvents = [ + 'on', + 'toggle_on', + 'btn_down', + 'single_push', + 'machine.on', + 'switch.on', + 'switch.toggle_on', + 'input.on', + 'input.toggle_on', + 'input.btn_down', + 'input.single_push', + ]; + $negativeEvents = [ + 'off', + 'toggle_off', + 'btn_up', + 'machine.off', + 'switch.off', + 'switch.toggle_off', + 'input.off', + 'input.toggle_off', + 'input.btn_up', + ]; + + $eventIsOn = in_array($eventName, $positiveEvents, true); + $eventIsOff = in_array($eventName, $negativeEvents, true); + $recognized = $eventIsOn || $eventIsOff || $on !== null; + $onState = $eventIsOn || ($on === true && !$eventIsOff); + + return [ + 'recognized' => $recognized, + 'on' => $onState, + 'event' => $event !== null ? (string)$event : null, + 'component' => $component, + 'relay_id' => $relayId, + 'device_id' => $deviceId, + 'channel' => $channel, + 'source' => (string)($payload['source'] ?? 'shelly'), + 'raw_status' => $this->extractStatusPayload($payload), + ]; + } + + /** + * @param array $payload + * @param array $context + * @return array + */ + public function recordCloudShellySignal(int $departmentId, ?int $laneId, array $payload, array $context = []): array + { + $signal = $this->normalizeShellyPayload($payload + ['source' => 'shelly_cloud']); + if (!$signal['recognized']) { + return [ + 'recorded' => false, + 'ignored' => true, + 'reason' => 'Payload does not contain a recognized Shelly ON/OFF signal.', + 'signal' => $signal, + ]; + } + if (!$signal['on']) { + return [ + 'recorded' => false, + 'ignored' => true, + 'reason' => 'Shelly signal was recognized but it was not ON.', + 'signal' => $signal, + ]; + } + + $resolvedLaneId = $this->resolveLaneId($departmentId, $laneId, $signal['relay_id'] ?? null); + $summary = (new selfserve_wash_flow())->recordMachineStartWebhook( + $resolvedLaneId, + $this->extractRegistration($payload), + $payload + [ + 'source' => 'shelly_cloud', + 'shelly_signal' => $signal, + 'context' => $context, + ] + ); + + return [ + 'recorded' => true, + 'ignored' => false, + 'lane_id' => $resolvedLaneId, + 'signal' => $signal, + 'selfserve' => $summary, + ]; + } + + /** + * @param array $payload + * @return array + */ + public function recordEdgeGatewaySignal(int $gatewayId, string $agentToken, array $payload): array + { + $gateway = (new edge_gateway_manager())->authenticateGateway($gatewayId, $agentToken); + $departmentId = (int)$gateway->department_id->value(); + + return $this->recordCloudShellySignal( + $departmentId, + isset($payload['lane_id']) ? (int)$payload['lane_id'] : null, + $payload + [ + 'source' => 'edge_gateway', + 'gateway_id' => $gatewayId, + ], + [ + 'source' => 'edge_gateway', + 'gateway_id' => $gatewayId, + 'agent_instance_id' => $payload['agent_instance_id'] ?? null, + ] + ); + } + + /** + * @return array + */ + public function listEdgeGatewayMachineSignalMonitors(int $gatewayId, string $agentToken): array + { + $manager = new edge_gateway_manager(); + $gateway = $manager->authenticateGateway($gatewayId, $agentToken); + $departmentId = (int)$gateway->department_id->value(); + + $bindings = []; + foreach ($this->bindingRows($gatewayId, $departmentId) as $binding) { + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId !== '') { + $bindings[$relayId] = $binding; + } + } + + $monitors = []; + foreach ((new department_lanes_o())->getDepartmentLanes($departmentId) as $lane) { + $relayId = trim((string)$lane->relay_machine_id->value()); + if ($relayId === '' || !isset($bindings[$relayId])) { + continue; + } + + $binding = $bindings[$relayId]; + $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; + $component = $this->normalizeMonitorComponent((string)($metadata['machine_signal_component'] ?? $metadata['signal_component'] ?? 'input')); + $channel = (int)($metadata['machine_signal_channel'] ?? $metadata['input_channel'] ?? $binding['channel'] ?? 0); + + $monitors[] = [ + 'gateway_id' => $gatewayId, + 'department_id' => $departmentId, + 'lane_id' => (int)$lane->id, + 'lane_label' => (string)$lane->name->value(), + 'relay_id' => $relayId, + 'device_id' => (string)($metadata['machine_signal_device_id'] ?? $binding['device_id'] ?? ''), + 'local_ip' => $metadata['machine_signal_local_ip'] ?? $binding['local_ip'] ?? null, + 'channel' => $channel, + 'component' => $component, + 'expected_event' => $component === 'switch' ? 'switch.on' : 'input.toggle_on', + ]; + } + + return [ + 'gateway_id' => $gatewayId, + 'department_id' => $departmentId, + 'monitors' => $monitors, + ]; + } + + public function resolveLaneId(int $departmentId, ?int $laneId, ?string $relayId = null): int + { + if ($laneId !== null && $laneId > 0) { + $lane = (new department_lanes_o())->select($laneId); + if (!$lane->exists()) { + throw new \RuntimeException('Department lane not found.'); + } + if ((int)$lane->department->value() !== $departmentId) { + throw new \RuntimeException('The lane does not belong to the Shelly signal department.'); + } + + return (int)$lane->id; + } + + $relayId = trim((string)$relayId); + if ($relayId !== '') { + $matches = (new department_lanes_o())->getFieldsWhere( + [ + 'department' => $departmentId, + 'relay_machine_id' => $relayId, + 'deleted_at' => null, + ], + ['id'] + ); + if ($matches !== []) { + return (int)$matches[0]['id']; + } + } + + $lanes = (new department_lanes_o())->getDepartmentLanes($departmentId); + if (count($lanes) === 1) { + return (int)$lanes[0]->id; + } + + throw new \RuntimeException('lane_id or relay_id is required when the department has multiple self-serve lanes.'); + } + + /** + * @param array $payload + */ + private function extractRegistration(array $payload): ?string + { + $reg = $this->firstString($payload, ['reg', 'registration', 'license_plate', 'licensePlate', 'plate']); + return $reg === null || trim($reg) === '' ? null : selfserve::standardize_registration($reg); + } + + /** + * @param array $payload + * @param array $keys + */ + private function firstString(array $payload, array $keys): ?string + { + foreach ($keys as $key) { + if (!array_key_exists($key, $payload)) { + continue; + } + $value = $payload[$key]; + if (is_scalar($value) && trim((string)$value) !== '') { + return trim((string)$value); + } + } + + foreach (['params', 'data', 'status'] as $container) { + if (!isset($payload[$container]) || !is_array($payload[$container])) { + continue; + } + $match = $this->firstString((array)$payload[$container], $keys); + if ($match !== null) { + return $match; + } + } + + return null; + } + + /** + * @param array $payload + * @param array $keys + */ + private function firstInt(array $payload, array $keys): ?int + { + foreach ($keys as $key) { + if (array_key_exists($key, $payload) && is_numeric($payload[$key])) { + return (int)$payload[$key]; + } + } + + foreach (['params', 'data', 'status'] as $container) { + if (!isset($payload[$container]) || !is_array($payload[$container])) { + continue; + } + $match = $this->firstInt((array)$payload[$container], $keys); + if ($match !== null) { + return $match; + } + } + + return null; + } + + /** + * @param array $payload + */ + private function extractOnState(array $payload): ?bool + { + foreach (['on', 'output', 'state', 'ison'] as $key) { + if (array_key_exists($key, $payload)) { + return $this->boolValue($payload[$key]); + } + } + + foreach (['input', 'switch', 'params', 'data', 'status'] as $key) { + if (!isset($payload[$key]) || !is_array($payload[$key])) { + continue; + } + $value = $this->extractOnState((array)$payload[$key]); + if ($value !== null) { + return $value; + } + } + + foreach (['input:0', 'switch:0'] as $componentKey) { + if (!isset($payload[$componentKey]) || !is_array($payload[$componentKey])) { + continue; + } + $value = $this->extractOnState((array)$payload[$componentKey]); + if ($value !== null) { + return $value; + } + } + + return null; + } + + private function boolValue(mixed $value): ?bool + { + if (is_bool($value)) { + return $value; + } + if (is_int($value) || is_float($value)) { + return (int)$value === 1; + } + if (is_string($value)) { + $normalized = strtolower(trim($value)); + if (in_array($normalized, ['1', 'true', 'on', 'yes'], true)) { + return true; + } + if (in_array($normalized, ['0', 'false', 'off', 'no'], true)) { + return false; + } + } + + return null; + } + + private function normalizeComponent(?string $component): ?string + { + $component = strtolower(trim((string)$component)); + if ($component === '') { + return null; + } + if (str_starts_with($component, 'input')) { + return 'input'; + } + if (str_starts_with($component, 'switch') || str_starts_with($component, 'relay')) { + return 'switch'; + } + + return null; + } + + private function normalizeMonitorComponent(string $component): string + { + return $this->normalizeComponent($component) === 'switch' ? 'switch' : 'input'; + } + + /** + * @param array $payload + * @return array|null + */ + private function extractStatusPayload(array $payload): ?array + { + if (isset($payload['status']) && is_array($payload['status'])) { + return (array)$payload['status']; + } + if (isset($payload['raw']) && is_array($payload['raw'])) { + return (array)$payload['raw']; + } + + return null; + } + + /** + * @return array> + */ + private function bindingRows(int $gatewayId, int $departmentId): array + { + $rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere( + [ + 'gateway_id' => $gatewayId, + 'department_id' => $departmentId, + 'deleted_at' => null, + ], + ['id'] + ); + + return array_map( + static fn(array $row): array => (new edge_gateway_relay_bindings_o())->select((int)$row['id'])->asArray(), + $rows + ); + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_studio_action_runner.php b/services/nginx/app/modules/selfserve/classes/selfserve_studio_action_runner.php new file mode 100644 index 00000000..287ed7d4 --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_studio_action_runner.php @@ -0,0 +1,208 @@ + $context + * @return array> + */ + public function executeForLaneEvent(int|object $lane, string $event, string $washMode = selfserve_studio_actions::MODE_BOTH, array $context = []): array + { + $laneObject = is_int($lane) ? (new selfserve())->lane($lane) : $lane; + $departmentId = $this->departmentIdForLane($laneObject); + if ($departmentId <= 0) { + return []; + } + + $published = (new selfserve_config_versioning())->getPublishedV2Config($departmentId); + $config = is_array($published['config'] ?? null) ? (array)$published['config'] : []; + $actions = $this->matchingActions($config, $laneObject, $event, $washMode, $context); + $results = []; + + foreach ($actions as $action) { + $results[] = $this->executeAction($laneObject, $action); + } + + return $results; + } + + /** + * @param array $config + * @param object $lane + * @param array $context + * @return array> + */ + public function matchingActions(array $config, object $lane, string $event, string $washMode, array $context = []): array + { + $event = strtolower(trim($event)); + $washMode = strtolower(trim($washMode)); + $laneId = (int)($lane->id ?? 0); + $departmentId = $this->departmentIdForLane($lane); + $machineTypeId = $this->nullableInt($context['machine_type_id'] ?? null) ?? $this->machineTypeIdForLane($lane); + $productId = $this->nullableInt($context['product'] ?? $context['product_id'] ?? $context['vehicle_type_id'] ?? null); + $conditionResults = is_array($context['condition_results'] ?? null) ? (array)$context['condition_results'] : null; + + $actions = []; + foreach ((array)($config['actions'] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + $action = selfserve_studio_actions::normalize((array)$row); + if (!$action['enabled'] || $action['event'] !== $event) { + continue; + } + if (!in_array($action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $washMode], true)) { + continue; + } + if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) { + continue; + } + if ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) { + continue; + } + if ((int)$action['product'] !== 0 && ($productId === null || (int)$action['product'] !== $productId)) { + continue; + } + if ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== $machineTypeId) { + continue; + } + $conditionId = $action['condition_id']; + if ($conditionId !== null && (($conditionResults[$conditionId] ?? false) !== true)) { + continue; + } + $actions[] = $action; + } + + usort($actions, static fn(array $left, array $right): int => ((int)$left['order_priority'] <=> (int)$right['order_priority']) ?: ((int)$left['id'] <=> (int)$right['id'])); + return $actions; + } + + /** + * @param object $lane + * @param array $action + * @return array + */ + private function executeAction(object $lane, array $action): array + { + $options = is_array($action['options'] ?? null) ? (array)$action['options'] : []; + $attempts = max(1, min(4, ((int)($options['retry_count'] ?? 0)) + 1)); + $delayMs = max(0, min(10000, (int)($options['delay_ms'] ?? 0))); + $lastError = null; + + for ($attempt = 1; $attempt <= $attempts; $attempt++) { + try { + if ($delayMs > 0) { + usleep($delayMs * 1000); + } + $this->dispatchAction($lane, $action); + return [ + 'action_id' => (int)$action['id'], + 'name' => (string)$action['name'], + 'event' => (string)$action['event'], + 'operation' => (string)$action['operation'], + 'status' => 'sent', + 'attempts' => $attempt, + ]; + } catch (\Throwable $e) { + $lastError = $e; + } + } + + $result = [ + 'action_id' => (int)$action['id'], + 'name' => (string)$action['name'], + 'event' => (string)$action['event'], + 'operation' => (string)$action['operation'], + 'status' => 'failed', + 'attempts' => $attempts, + 'error' => $lastError?->getMessage(), + ]; + + if (($options['failure_policy'] ?? selfserve_studio_actions::FAILURE_CONTINUE) === selfserve_studio_actions::FAILURE_BLOCK) { + throw new \RuntimeException('Self-serve studio action failed: ' . (string)$action['name'], 0, $lastError); + } + + return $result; + } + + /** + * @param object $lane + * @param array $action + */ + private function dispatchAction(object $lane, array $action): void + { + $toggleAfter = $this->nullableInt($action['options']['toggle_after_seconds'] ?? null); + switch ((string)$action['operation']) { + case selfserve_studio_actions::OP_OPEN_PROPERTY_ENTRANCE_GATE: + $lane->execute(selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE, new selfserve_lane_command_arguments()); + return; + case selfserve_studio_actions::OP_OPEN_PROPERTY_EXIT_GATE: + $lane->execute(selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE, new selfserve_lane_command_arguments()); + return; + case selfserve_studio_actions::OP_OPEN_LANE_ENTRANCE_PORT: + $lane->open(selfserve_lane_port::ENTRANCE, $toggleAfter); + return; + case selfserve_studio_actions::OP_OPEN_LANE_EXIT_PORT: + $lane->open(selfserve_lane_port::EXIT, $toggleAfter); + return; + case selfserve_studio_actions::OP_SET_CLEANER_RELAY: + $lane->setRelayStatusHard(selfserve_lane_relay::MACHINE_CLEANER, (bool)$action['relay_state']); + return; + case selfserve_studio_actions::OP_SET_MACHINE_RELAY: + $lane->setRelayStatusHard(selfserve_lane_relay::MACHINE, (bool)$action['relay_state']); + return; + case selfserve_studio_actions::OP_SET_PROGRAM_PICKER_RELAY: + $lane->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, (bool)$action['relay_state']); + return; + } + + throw new \RuntimeException('Unsupported self-serve studio action operation: ' . (string)$action['operation']); + } + + private function departmentIdForLane(object $lane): int + { + try { + return empty($lane->department_lane) || empty($lane->department_lane->department) + ? 0 + : (int)$lane->department_lane->department->value(); + } catch (\Throwable) { + return 0; + } + } + + private function machineTypeIdForLane(object $lane): int + { + try { + return empty($lane->department_lane) || empty($lane->department_lane->machine_type_id) + ? 0 + : (int)$lane->department_lane->machine_type_id->value(); + } catch (\Throwable) { + return 0; + } + } + + private function nullableInt(mixed $value): ?int + { + if ($value === null || $value === '' || $value === 'null') { + return null; + } + $intValue = (int)$value; + return $intValue <= 0 ? null : $intValue; + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_studio_actions.php b/services/nginx/app/modules/selfserve/classes/selfserve_studio_actions.php new file mode 100644 index 00000000..7101a325 --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_studio_actions.php @@ -0,0 +1,217 @@ + + */ + public static function events(): array + { + return [ + self::EVENT_WASH_START_COMMAND, + self::EVENT_WASH_STOP_COMMAND, + self::EVENT_MACHINE_START_TRIGGERED, + ]; + } + + /** + * @return array + */ + public static function washModes(): array + { + return [ + self::MODE_MANUAL, + self::MODE_MACHINE, + self::MODE_BOTH, + ]; + } + + /** + * @return array + */ + public static function operations(): array + { + return [ + self::OP_OPEN_PROPERTY_ENTRANCE_GATE, + self::OP_OPEN_PROPERTY_EXIT_GATE, + self::OP_OPEN_LANE_ENTRANCE_PORT, + self::OP_OPEN_LANE_EXIT_PORT, + self::OP_SET_CLEANER_RELAY, + self::OP_SET_MACHINE_RELAY, + self::OP_SET_PROGRAM_PICKER_RELAY, + ]; + } + + /** + * @return array + */ + public static function failurePolicies(): array + { + return [ + self::FAILURE_CONTINUE, + self::FAILURE_BLOCK, + ]; + } + + public static function isRelayOperation(string $operation): bool + { + return in_array($operation, [ + self::OP_SET_CLEANER_RELAY, + self::OP_SET_MACHINE_RELAY, + self::OP_SET_PROGRAM_PICKER_RELAY, + ], true); + } + + public static function isOpenOperation(string $operation): bool + { + return in_array($operation, [ + self::OP_OPEN_PROPERTY_ENTRANCE_GATE, + self::OP_OPEN_PROPERTY_EXIT_GATE, + self::OP_OPEN_LANE_ENTRANCE_PORT, + self::OP_OPEN_LANE_EXIT_PORT, + ], true); + } + + public static function relayRoleForOperation(string $operation): string + { + return match ($operation) { + self::OP_OPEN_PROPERTY_ENTRANCE_GATE => 'PROPERTY_ENTRANCE', + self::OP_OPEN_PROPERTY_EXIT_GATE => 'PROPERTY_EXIT', + self::OP_OPEN_LANE_ENTRANCE_PORT => 'ENTRY', + self::OP_OPEN_LANE_EXIT_PORT => 'EXIT', + self::OP_SET_CLEANER_RELAY => 'CLEANER', + self::OP_SET_MACHINE_RELAY => 'MACHINE', + self::OP_SET_PROGRAM_PICKER_RELAY => 'PROGRAM_PICKER', + default => 'ACTION', + }; + } + + public static function eventLabel(string $event): string + { + return match ($event) { + self::EVENT_WASH_START_COMMAND => 'When wash starts', + self::EVENT_WASH_STOP_COMMAND => 'When wash stops', + self::EVENT_MACHINE_START_TRIGGERED => 'When the machine start button is triggered', + default => 'On action event', + }; + } + + public static function operationLabel(string $operation, ?bool $relayState = null): string + { + $state = $relayState === null ? '' : ($relayState ? 'ON ' : 'OFF '); + return match ($operation) { + self::OP_OPEN_PROPERTY_ENTRANCE_GATE => 'Open property entrance gate', + self::OP_OPEN_PROPERTY_EXIT_GATE => 'Open property exit gate', + self::OP_OPEN_LANE_ENTRANCE_PORT => 'Open lane entrance port', + self::OP_OPEN_LANE_EXIT_PORT => 'Open lane exit port', + self::OP_SET_CLEANER_RELAY => 'Turn ' . $state . 'CLEANER', + self::OP_SET_MACHINE_RELAY => 'Turn ' . $state . 'MACHINE', + self::OP_SET_PROGRAM_PICKER_RELAY => 'Turn ' . $state . 'PROGRAM PICKER', + default => 'Action', + }; + } + + public static function runtimeStageForEvent(string $event): string + { + return match ($event) { + self::EVENT_WASH_START_COMMAND => 'start', + self::EVENT_WASH_STOP_COMMAND => 'stop', + self::EVENT_MACHINE_START_TRIGGERED => 'machine_start', + default => 'action', + }; + } + + /** + * @param array $action + * @return array + */ + public static function normalize(array $action): array + { + $event = strtolower(trim((string)($action['event'] ?? self::EVENT_WASH_START_COMMAND))); + if (!in_array($event, self::events(), true)) { + $event = self::EVENT_WASH_START_COMMAND; + } + + $operation = strtolower(trim((string)($action['operation'] ?? self::OP_OPEN_LANE_ENTRANCE_PORT))); + if (!in_array($operation, self::operations(), true)) { + $operation = self::OP_OPEN_LANE_ENTRANCE_PORT; + } + + $washMode = strtolower(trim((string)($action['wash_mode'] ?? self::MODE_BOTH))); + if (!in_array($washMode, self::washModes(), true)) { + $washMode = self::MODE_BOTH; + } + + $options = is_array($action['options'] ?? null) ? (array)$action['options'] : []; + $failurePolicy = strtolower(trim((string)($options['failure_policy'] ?? self::FAILURE_CONTINUE))); + if (!in_array($failurePolicy, self::failurePolicies(), true)) { + $failurePolicy = self::FAILURE_CONTINUE; + } + + $relayState = array_key_exists('relay_state', $action) + ? filter_var($action['relay_state'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) + : null; + if (self::isRelayOperation($operation) && $relayState === null) { + $relayState = true; + } + + $label = trim((string)($action['name'] ?? $action['label'] ?? '')); + if ($label === '') { + $label = self::operationLabel($operation, $relayState); + } + + return [ + 'id' => (int)($action['id'] ?? 0), + 'department' => (int)($action['department'] ?? 0), + 'lane' => (int)($action['lane'] ?? 0), + 'product' => (int)($action['product'] ?? 0), + 'machine_type_id' => self::nullableInt($action['machine_type_id'] ?? null), + 'condition_id' => self::nullableInt($action['condition_id'] ?? null), + 'name' => $label, + 'description' => (string)($action['description'] ?? ''), + 'event' => $event, + 'wash_mode' => $washMode, + 'operation' => $operation, + 'relay_state' => $relayState, + 'enabled' => filter_var($action['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN), + 'order_priority' => (int)($action['order_priority'] ?? 0), + 'options' => [ + 'delay_ms' => max(0, (int)($options['delay_ms'] ?? 0)), + 'toggle_after_seconds' => self::nullableInt($options['toggle_after_seconds'] ?? null), + 'retry_count' => max(0, min(3, (int)($options['retry_count'] ?? 0))), + 'failure_policy' => $failurePolicy, + 'record_event' => filter_var($options['record_event'] ?? true, FILTER_VALIDATE_BOOLEAN), + ], + ]; + } + + private static function nullableInt(mixed $value): ?int + { + if ($value === null || $value === '' || $value === 'null') { + return null; + } + $intValue = (int)$value; + return $intValue <= 0 ? null : $intValue; + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php b/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php new file mode 100644 index 00000000..3621d958 --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_studio_graph.php @@ -0,0 +1,5210 @@ +> */ + private array $columnCache = []; + + public function __construct() + { + selfserve_schema_bootstrap::ensureTables(); + } + + /** + * @param array $permissions + * @return array + */ + public function buildGraph(int $departmentId, ?int $userId = null, array $permissions = []): array + { + $versioning = new selfserve_config_versioning(); + $draft = $versioning->ensureDraftFromLegacy($departmentId, $userId, false); + $config = is_array($draft['config'] ?? null) ? (array)$draft['config'] : $versioning->snapshotLegacyConfig($departmentId); + $gatewayWorkspace = ($permissions['modules_shelly_config'] ?? false) + ? $this->buildGatewayWorkspace($departmentId) + : [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [], + 'issues' => [], + 'actions' => [], + 'restricted' => true, + ]; + $lookups = $this->buildLookups($departmentId, $config, $gatewayWorkspace); + $layout = $this->loadLayout($departmentId, $userId); + $configWithAttachments = $this->withTaskAttachments($config); + $graph = $this->buildGraphFromConfig($configWithAttachments, [ + 'department_id' => $departmentId, + 'lookups' => $lookups, + 'gateway_workspace' => $gatewayWorkspace, + ], $layout); + + $validation = $versioning->validateConfig($config); + $virtualWarnings = (new selfserve_virtual_hardware())->validationWarnings($gatewayWorkspace); + if ($virtualWarnings !== []) { + $validation['warnings'] = array_values(array_unique(array_merge((array)($validation['warnings'] ?? []), $virtualWarnings))); + } + $validation['items'] = $this->buildValidationItems($validation); + + return [ + 'nodes' => $graph['nodes'], + 'edges' => $graph['edges'], + 'lookups' => $lookups, + 'validation' => $validation, + 'layout' => $layout, + 'versions' => $versioning->listVersions($departmentId), + 'active_config' => $versioning->getPublishedConfig($departmentId), + 'draft' => [ + 'id' => $draft['id'] ?? null, + 'status' => $draft['status'] ?? selfserve_config_versioning::STATUS_DRAFT, + 'version_number' => $draft['version_number'] ?? null, + 'created_at' => $draft['created_at'] ?? null, + 'updated_at' => $draft['updated_at'] ?? null, + ], + 'simulator_defaults' => $this->buildSimulatorDefaults($departmentId, $lookups, $gatewayWorkspace), + 'gateway_workspace' => $gatewayWorkspace, + 'permissions' => $permissions, + 'meta' => [ + 'department_id' => $departmentId, + 'layout_affects_runtime' => false, + 'generated_at' => date('c'), + 'path_editor' => is_array($config['v2_meta']['path_editor'] ?? null) ? (array)$config['v2_meta']['path_editor'] : ['paths' => []], + ], + ]; + } + + /** + * Pure graph builder used by unit tests and the API serializer. + * + * @param array $config + * @param array $context + * @param array $layout + * @return array{nodes:array>,edges:array>} + */ + public function buildGraphFromConfig(array $config, array $context = [], array $layout = []): array + { + $lookups = is_array($context['lookups'] ?? null) ? (array)$context['lookups'] : []; + $gatewayWorkspace = is_array($context['gateway_workspace'] ?? null) ? (array)$context['gateway_workspace'] : []; + $isV2Config = (int)($config['schema_version'] ?? 0) === selfserve_config_versioning::SCHEMA_VERSION_V2; + $nodes = []; + $edges = []; + + $nodes[] = $this->node('checkpoint:start', 'input', 'Runtime start', 'runtime_checkpoint', [ + 'stage' => 'start', + 'subtitle' => 'Vehicle scanned', + ], 0, 0); + $nodes[] = $this->node('checkpoint:eligible', 'default', 'Eligibility resolved', 'runtime_checkpoint', [ + 'stage' => 'eligible', + 'subtitle' => 'Questions, rules, and gates evaluated', + ], 320, 0); + $nodes[] = $this->node('checkpoint:finish', 'output', 'Wash complete', 'runtime_checkpoint', [ + 'stage' => 'finish', + 'subtitle' => 'Session closed', + ], 640, 0); + $edges[] = $this->edge('runtime:start-eligible', 'checkpoint:start', 'checkpoint:eligible', 'runtime', 'runtime'); + $edges[] = $this->edge('runtime:eligible-finish', 'checkpoint:eligible', 'checkpoint:finish', 'runtime', 'runtime'); + + foreach ($this->lookupRows($lookups, 'lanes') as $index => $lane) { + $id = 'lane:' . (int)($lane['id'] ?? 0); + $nodes[] = $this->node($id, 'default', (string)($lane['label'] ?? ('Lane ' . ($lane['id'] ?? ''))), 'lane', [ + 'object_id' => (int)($lane['id'] ?? 0), + 'raw' => $lane, + 'subtitle' => 'Lane scope', + ], 0, 180 + ($index * 120)); + } + + foreach ($this->lookupRows($lookups, 'machine_types') as $index => $machineType) { + $id = 'machine_type:' . (int)($machineType['id'] ?? 0); + $nodes[] = $this->node($id, 'default', (string)($machineType['label'] ?? ('Machine type ' . ($machineType['id'] ?? ''))), 'machine_type', [ + 'object_id' => (int)($machineType['id'] ?? 0), + 'raw' => $machineType, + 'subtitle' => 'Reusable machine setup', + ], 0, 560 + ($index * 120)); + } + + foreach ($this->lookupRows($lookups, 'vehicle_types') as $index => $vehicleType) { + $id = 'vehicle_type:' . (int)($vehicleType['id'] ?? 0); + $nodes[] = $this->node($id, 'default', (string)($vehicleType['label'] ?? ('Vehicle type ' . ($vehicleType['id'] ?? ''))), 'vehicle_type', [ + 'object_id' => (int)($vehicleType['id'] ?? 0), + 'raw' => $vehicleType, + 'subtitle' => 'Vehicle scope', + ], 0, 880 + ($index * 120)); + } + + foreach ($this->sortedRows((array)($config['conditions'] ?? []), ['name', 'id']) as $index => $condition) { + $id = (int)($condition['id'] ?? 0); + $nodes[] = $this->node('condition:' . $id, 'default', $this->entityLabel('condition', $id, $condition, $lookups), 'condition', [ + 'object_id' => $id, + 'raw' => $condition, + 'scope' => $this->scopeForRow($condition, $lookups), + 'expression_summary' => $this->expressionSummary(is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : []), + 'subtitle' => $this->scopeLabel($condition, $lookups), + ], 360, 160 + ($index * 130)); + + $parentId = $this->nullableInt($condition['condition_id'] ?? null); + if ($parentId !== null) { + $edges[] = $this->edge('condition-parent:' . $parentId . ':' . $id, 'condition:' . $parentId, 'condition:' . $id, 'condition_group', 'parent'); + } + if ($isV2Config && is_array($condition['expression'] ?? null)) { + $this->appendExpressionEdges($edges, 'condition:' . $id, $id, (array)$condition['expression']); + } + $this->appendScopeEdges($edges, 'condition:' . $id, $condition); + } + + foreach ($this->sortedRows((array)($config['questions'] ?? []), ['order_priority', 'id']) as $index => $question) { + $id = (int)($question['id'] ?? 0); + $nodes[] = $this->node('question:' . $id, 'default', $this->entityLabel('question', $id, $question, $lookups), 'question', [ + 'object_id' => $id, + 'raw' => $question, + 'scope' => $this->scopeForRow($question, $lookups), + 'subtitle' => $this->scopeLabel($question, $lookups), + ], 720, 160 + ($index * 130)); + + $conditionId = $this->nullableInt($question['condition_id'] ?? null); + if ($conditionId !== null) { + $edges[] = $this->edge('question-gate:' . $conditionId . ':' . $id, 'condition:' . $conditionId, 'question:' . $id, 'visibility_gate', 'show if'); + } + $this->appendScopeEdges($edges, 'question:' . $id, $question); + } + + if (!$isV2Config) { + foreach ($this->sortedRows((array)($config['rules'] ?? []), ['condition_id', 'id']) as $index => $rule) { + $id = (int)($rule['id'] ?? 0); + $nodes[] = $this->node('rule:' . $id, 'default', $this->entityLabel('rule', $id, $rule, $lookups), 'rule', [ + 'object_id' => $id, + 'raw' => $rule, + 'subtitle' => $this->ruleSubtitle($rule, $lookups), + ], 520, 520 + ($index * 120)); + + $conditionId = (int)($rule['condition_id'] ?? 0); + if ($conditionId > 0) { + $edges[] = $this->edge('rule-owner:' . $conditionId . ':' . $id, 'rule:' . $id, 'condition:' . $conditionId, 'condition_rule', 'rule of'); + } + $objectType = strtolower((string)($rule['object_type'] ?? '')); + $objectId = (int)($rule['object_id'] ?? 0); + if (in_array($objectType, ['question', 'condition', 'task'], true) && $objectId > 0) { + $edges[] = $this->edge('rule-input:' . $objectType . ':' . $objectId . ':' . $id, $objectType . ':' . $objectId, 'rule:' . $id, 'rule_input', (string)($rule['type'] ?? 'rule')); + } + } + } + + $tasksByScope = []; + $taskRows = $this->sortedRows((array)($config['tasks'] ?? []), ['order_priority', 'id']); + foreach ($taskRows as $index => $task) { + $id = (int)($task['id'] ?? 0); + $nodes[] = $this->node('task:' . $id, 'default', $this->entityLabel('task', $id, $task, $lookups), 'task', [ + 'object_id' => $id, + 'raw' => $this->normalizeTaskPayload($task), + 'scope' => $this->scopeForRow($task, $lookups), + 'subtitle' => $this->scopeLabel($task, $lookups), + ], 1080, 160 + ($index * 130)); + + $gateType = strtoupper((string)($task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value)); + $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); + if ($gateType === selfserve_task_gate_type::CONDITION->value && $gateRefId !== null) { + $edges[] = $this->edge('task-gate:condition:' . $gateRefId . ':' . $id, 'condition:' . $gateRefId, 'task:' . $id, 'task_gate', 'unlocks'); + } elseif ($gateType === selfserve_task_gate_type::QUESTION->value && $gateRefId !== null) { + $edges[] = $this->edge('task-gate:question:' . $gateRefId . ':' . $id, 'question:' . $gateRefId, 'task:' . $id, 'task_gate', 'unlocks'); + } + + $scopeKey = implode(':', [ + (int)($task['department'] ?? 0), + (int)($task['lane'] ?? 0), + (int)($task['product'] ?? 0), + (int)($task['machine_type_id'] ?? 0), + ]); + $tasksByScope[$scopeKey][] = $task; + $this->appendScopeEdges($edges, 'task:' . $id, $task); + } + + foreach ($tasksByScope as $tasks) { + $orderedTasks = $this->sortedRows($tasks, ['order_priority', 'id']); + for ($i = 1; $i < count($orderedTasks); $i++) { + $sourceId = (int)($orderedTasks[$i - 1]['id'] ?? 0); + $targetId = (int)($orderedTasks[$i]['id'] ?? 0); + if ($sourceId > 0 && $targetId > 0) { + $edges[] = $this->edge('task-order:' . $sourceId . ':' . $targetId, 'task:' . $sourceId, 'task:' . $targetId, 'task_order', 'then'); + } + } + } + + $actionsByEvent = []; + $actionRows = $this->sortedRows((array)($config['actions'] ?? []), ['event', 'order_priority', 'id']); + foreach ($actionRows as $index => $action) { + $normalizedAction = selfserve_studio_actions::normalize($action); + $id = (int)($normalizedAction['id'] ?? 0); + if ($id <= 0) { + continue; + } + $actionsByEvent[$normalizedAction['event']][] = $normalizedAction; + $nodes[] = $this->node('action:' . $id, 'default', $this->entityLabel('action', $id, $normalizedAction, $lookups), 'action', [ + 'object_id' => $id, + 'raw' => $normalizedAction, + 'scope' => $this->scopeForRow($normalizedAction, $lookups), + 'subtitle' => $this->actionSubtitle($normalizedAction, $lookups), + 'action_label' => selfserve_studio_actions::operationLabel((string)$normalizedAction['operation'], $normalizedAction['relay_state']), + 'event_label' => selfserve_studio_actions::eventLabel((string)$normalizedAction['event']), + 'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$normalizedAction['operation']), + ], 1360, 160 + ($index * 130)); + + $eventSource = match ((string)$normalizedAction['event']) { + selfserve_studio_actions::EVENT_WASH_START_COMMAND => 'checkpoint:start', + selfserve_studio_actions::EVENT_WASH_STOP_COMMAND => 'checkpoint:finish', + selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED => 'checkpoint:eligible', + default => 'checkpoint:start', + }; + $edges[] = $this->edge('action-event:' . $normalizedAction['event'] . ':' . $id, $eventSource, 'action:' . $id, 'action_event', $normalizedAction['wash_mode']); + + $conditionId = $this->nullableInt($normalizedAction['condition_id'] ?? null); + if ($conditionId !== null) { + $edges[] = $this->edge('action-gate:' . $conditionId . ':' . $id, 'condition:' . $conditionId, 'action:' . $id, 'action_gate', 'allows'); + } + $this->appendScopeEdges($edges, 'action:' . $id, $normalizedAction); + } + + foreach ($actionsByEvent as $event => $actions) { + $orderedActions = $this->sortedRows($actions, ['order_priority', 'id']); + for ($i = 1; $i < count($orderedActions); $i++) { + $sourceId = (int)($orderedActions[$i - 1]['id'] ?? 0); + $targetId = (int)($orderedActions[$i]['id'] ?? 0); + if ($sourceId > 0 && $targetId > 0) { + $edges[] = $this->edge('action-order:' . $event . ':' . $sourceId . ':' . $targetId, 'action:' . $sourceId, 'action:' . $targetId, 'action_order', 'then'); + } + } + } + + $this->appendGatewayNodesAndEdges($nodes, $edges, $gatewayWorkspace); + $this->appendTaskServiceEdges($edges, $taskRows, $gatewayWorkspace); + $this->appendActionRelayEdges($edges, $actionRows, $gatewayWorkspace); + + return [ + 'nodes' => $this->applyLayoutToNodes($nodes, $layout), + 'edges' => array_values($edges), + ]; + } + + /** + * @param array $payload + * @param array $permissions + * @return array + */ + public function applyGraphSave(int $departmentId, array $payload, ?int $userId = null, array $permissions = []): array + { + $operations = isset($payload['operations']) && is_array($payload['operations']) ? (array)$payload['operations'] : []; + + $versioning = new selfserve_config_versioning(); + $draft = $versioning->ensureDraftFromLegacy($departmentId, $userId, false); + $config = is_array($draft['config'] ?? null) ? (array)$draft['config'] : $versioning->snapshotLegacyConfig($departmentId); + + if ($versioning->isV2Config($config)) { + foreach ($operations as $operation) { + if (is_array($operation)) { + $this->applyConfigOperation($departmentId, $config, $operation, $permissions); + } + } + + $validation = $versioning->validateConfig($config); + $draftObject = (new selfserve_config_versions_o())->select((int)($draft['id'] ?? 0)); + if (!$draftObject->exists()) { + throw new \RuntimeException('Self-serve draft version was not found.'); + } + $draftObject->config_json->set($config); + $draftObject->validation_result_json->set($validation); + } else { + foreach ($operations as $operation) { + if (is_array($operation)) { + $this->applyOperation($departmentId, $operation, $permissions); + } + } + } + + if (isset($payload['layout']) && is_array($payload['layout'])) { + $this->saveLayout($departmentId, $userId, (array)$payload['layout']); + } elseif (isset($payload['nodes']) && is_array($payload['nodes'])) { + $this->saveLayout($departmentId, $userId, [ + 'nodes' => $this->extractNodePositions((array)$payload['nodes']), + 'viewport' => is_array($payload['viewport'] ?? null) ? (array)$payload['viewport'] : [], + ]); + } + + if (!$versioning->isV2Config($config)) { + $versioning->syncDraftFromLegacyForDepartment($departmentId); + } + return $this->buildGraph($departmentId, $userId, $permissions); + } + + /** + * @param array $layout + * @return array + */ + public function saveLayout(int $departmentId, ?int $userId, array $layout): array + { + $normalized = [ + 'nodes' => $this->extractNodePositions((array)($layout['nodes'] ?? [])), + 'viewport' => is_array($layout['viewport'] ?? null) ? (array)$layout['viewport'] : [], + 'saved_at' => date('c'), + 'runtime_affecting' => false, + ]; + $layoutJson = json_encode($normalized, JSON_UNESCAPED_UNICODE); + if ($layoutJson === false) { + throw new \RuntimeException('Failed to encode studio layout JSON: ' . json_last_error_msg()); + } + + $pdo = db::getPDO(); + $statement = $pdo->prepare( + "SELECT id + FROM department_selfserve_studio_layouts + WHERE department_id = :department_id + AND " . ($userId === null ? "user_id IS NULL" : "user_id = :user_id") . " + AND deleted_at IS NULL + ORDER BY id DESC + LIMIT 1" + ); + $params = [':department_id' => $departmentId]; + if ($userId !== null) { + $params[':user_id'] = $userId; + } + $statement->execute($params); + $row = $statement->fetch(\PDO::FETCH_ASSOC); + + if (is_array($row) && (int)($row['id'] ?? 0) > 0) { + $update = $pdo->prepare( + "UPDATE department_selfserve_studio_layouts + SET layout_json = :layout_json, updated_at = NOW() + WHERE id = :id" + ); + $update->execute([ + ':layout_json' => $layoutJson, + ':id' => (int)$row['id'], + ]); + } else { + $insert = $pdo->prepare( + "INSERT INTO department_selfserve_studio_layouts (department_id, user_id, layout_json) + VALUES (:department_id, :user_id, :layout_json)" + ); + $insert->execute([ + ':department_id' => $departmentId, + ':user_id' => $userId, + ':layout_json' => $layoutJson, + ]); + } + + return $normalized; + } + + /** + * @return array + */ + public function loadLayout(int $departmentId, ?int $userId): array + { + $pdo = db::getPDO(); + $statement = $pdo->prepare( + "SELECT layout_json + FROM department_selfserve_studio_layouts + WHERE department_id = :department_id + AND deleted_at IS NULL + AND (user_id = :user_id_filter OR user_id IS NULL) + ORDER BY CASE WHEN user_id = :user_id_sort THEN 0 ELSE 1 END, updated_at DESC, id DESC + LIMIT 1" + ); + $statement->execute([ + ':department_id' => $departmentId, + ':user_id_filter' => $userId, + ':user_id_sort' => $userId, + ]); + $row = $statement->fetch(\PDO::FETCH_ASSOC); + if (!is_array($row)) { + return [ + 'nodes' => [], + 'viewport' => [], + 'runtime_affecting' => false, + ]; + } + + $layout = json_decode((string)($row['layout_json'] ?? '{}'), true); + if (!is_array($layout)) { + $layout = []; + } + $layout['runtime_affecting'] = false; + return $layout; + } + + /** + * @param array $payload + * @return array + */ + public function validatePayload(int $departmentId, array $payload = []): array + { + $versioning = new selfserve_config_versioning(); + $draft = $versioning->ensureDraftFromLegacy($departmentId, null, false); + $config = is_array($payload['config'] ?? null) + ? (array)$payload['config'] + : (is_array($draft['config'] ?? null) ? (array)$draft['config'] : $versioning->snapshotLegacyConfig($departmentId)); + if (isset($payload['operations']) && is_array($payload['operations']) && $versioning->isV2Config($config)) { + foreach ((array)$payload['operations'] as $operation) { + if (is_array($operation)) { + $this->applyConfigOperation($departmentId, $config, $operation); + } + } + } + $validation = $versioning->validateConfig($config); + $gatewayWorkspace = $this->buildGatewayWorkspace($departmentId); + $virtualWarnings = (new selfserve_virtual_hardware())->validationWarnings($gatewayWorkspace); + if ($virtualWarnings !== []) { + $validation['warnings'] = array_values(array_unique(array_merge((array)($validation['warnings'] ?? []), $virtualWarnings))); + } + $validation['items'] = $this->buildValidationItems($validation); + return $validation; + } + + /** + * @param array $payload + * @param array $permissions + * @return array + */ + public function simulateGraph(int $departmentId, array $payload, ?int $userId = null, array $permissions = []): array + { + $laneId = (int)($payload['lane_id'] ?? 0); + if ($laneId <= 0) { + throw new \RuntimeException('lane_id is required for studio simulation.'); + } + + $configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft'))); + if (!in_array($configSource, ['draft', 'published'], true)) { + $configSource = 'draft'; + } + + $versioning = new selfserve_config_versioning(); + if ($configSource === 'published') { + $version = $versioning->getPublishedV2Config($departmentId); + $config = is_array($version['config'] ?? null) ? (array)$version['config'] : null; + $versionId = isset($version['version_id']) ? (int)$version['version_id'] : null; + } else { + $version = $versioning->ensureDraftFromLegacy($departmentId, $userId, false); + $config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId); + $versionId = isset($version['id']) ? (int)$version['id'] : null; + } + + $includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + $includeHardware = $includeHardware !== false; + $hardwareMode = strtolower(trim((string)($payload['hardware_mode'] ?? ''))); + if ($hardwareMode === '') { + $hardwareMode = $includeHardware ? 'studio' : 'none'; + } + if (!in_array($hardwareMode, ['studio', 'real', 'none'], true)) { + $hardwareMode = 'studio'; + } + if ($hardwareMode === 'none') { + $includeHardware = false; + } + if (!$includeHardware || !($permissions['modules_shelly_config'] ?? false)) { + $gatewayWorkspace = [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [], + 'issues' => [], + 'actions' => [], + 'restricted' => !$includeHardware ? false : true, + 'virtual' => [ + 'enabled' => $hardwareMode === 'studio', + 'has_virtual_hardware' => false, + 'gateway_count' => 0, + 'binding_count' => 0, + ], + ]; + } else { + $gatewayWorkspace = $this->buildGatewayWorkspace($departmentId, $hardwareMode !== 'real'); + } + $graphConfig = is_array($config) ? $config : $versioning->snapshotLegacyConfig($departmentId); + $lookups = $this->buildLookups($departmentId, $graphConfig, $gatewayWorkspace); + $graph = $this->buildGraphFromConfig($graphConfig, [ + 'department_id' => $departmentId, + 'lookups' => $lookups, + 'gateway_workspace' => $gatewayWorkspace, + ]); + + return (new selfserve_wash_flow())->previewStudioSimulation( + $departmentId, + $laneId, + (string)($payload['reg'] ?? ''), + array_key_exists('customer_number', $payload) ? $this->nullableInt($payload['customer_number']) : null, + array_key_exists('vehicle_type_id', $payload) ? $this->nullableInt($payload['vehicle_type_id']) : null, + [ + 'mode' => 'full_dry_run', + 'config_source' => $configSource, + 'config_payload' => $config, + 'config_version_id' => $versionId, + 'answer_overrides' => is_array($payload['answer_overrides'] ?? null) ? (array)$payload['answer_overrides'] : [], + 'include_hardware' => $includeHardware, + 'hardware_mode' => $hardwareMode, + 'lookups' => $lookups, + 'gateway_workspace' => $gatewayWorkspace, + 'graph' => $graph, + ], + ); + } + + /** + * @param array $payload + * @param array $permissions + * @return array + */ + public function projectPathOutcomes( + int $departmentId, + array $payload, + ?int $userId = null, + array $permissions = [], + ?callable $progressCallback = null + ): array + { + $configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft'))); + if (!in_array($configSource, ['draft', 'published'], true)) { + $configSource = 'draft'; + } + + $versioning = new selfserve_config_versioning(); + if ($configSource === 'published') { + $version = $versioning->getPublishedV2Config($departmentId); + $config = is_array($version['config'] ?? null) ? (array)$version['config'] : null; + $versionId = isset($version['version_id']) ? (int)$version['version_id'] : null; + } else { + $version = $versioning->ensureDraftFromLegacy($departmentId, $userId, false); + $config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId); + $versionId = isset($version['id']) ? (int)$version['id'] : null; + } + + $includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + $includeHardware = $includeHardware !== false; + $hardwareMode = strtolower(trim((string)($payload['hardware_mode'] ?? ''))); + if ($hardwareMode === '') { + $hardwareMode = $includeHardware ? 'studio' : 'none'; + } + if (!in_array($hardwareMode, ['studio', 'real', 'none'], true)) { + $hardwareMode = 'studio'; + } + if ($hardwareMode === 'none') { + $includeHardware = false; + } + + if (!$includeHardware || !($permissions['modules_shelly_config'] ?? false)) { + $gatewayWorkspace = [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [], + 'issues' => [], + 'actions' => [], + 'restricted' => !$includeHardware ? false : true, + 'virtual' => [ + 'enabled' => $hardwareMode === 'studio', + 'has_virtual_hardware' => false, + 'gateway_count' => 0, + 'binding_count' => 0, + ], + ]; + } else { + $gatewayWorkspace = $this->buildGatewayWorkspace($departmentId, $hardwareMode !== 'real'); + } + + $graphConfig = is_array($config) ? $config : $versioning->snapshotLegacyConfig($departmentId); + $lookups = $this->buildLookups($departmentId, $graphConfig, $gatewayWorkspace); + $graph = $this->buildGraphFromConfig($graphConfig, [ + 'department_id' => $departmentId, + 'lookups' => $lookups, + 'gateway_workspace' => $gatewayWorkspace, + ]); + $defaults = $this->buildSimulatorDefaults($departmentId, $lookups, $gatewayWorkspace); + $laneId = $this->nullableInt($payload['lane_id'] ?? null) ?? $this->nullableInt($defaults['lane_id'] ?? null); + if ($laneId === null) { + throw new \RuntimeException('No lane is available for path outcome projection.'); + } + + $vehicleTypeId = $this->nullableInt($payload['vehicle_type_id'] ?? null); + $vehicleTypeIds = []; + if ($vehicleTypeId !== null) { + $vehicleTypeIds[] = $vehicleTypeId; + } else { + foreach ($this->lookupRows($lookups, 'vehicle_types') as $row) { + $id = $this->nullableInt($row['id'] ?? null); + if ($id !== null) { + $vehicleTypeIds[$id] = $id; + } + } + $vehicleTypeIds = array_values($vehicleTypeIds); + } + if ($vehicleTypeIds === []) { + $vehicleTypeIds[] = null; + } + + $maxStates = $this->pathLimit($payload['max_states'] ?? null); + $reg = trim((string)($payload['reg'] ?? $defaults['reg'] ?? 'TEST123')); + if ($reg === '') { + $reg = 'TEST123'; + } + $customerNumber = array_key_exists('customer_number', $payload) + ? $this->nullableInt($payload['customer_number']) + : $this->nullableInt($defaults['customer_number'] ?? null); + + $flow = new selfserve_wash_flow(); + $outcomes = []; + $warnings = []; + $truncated = false; + $stateCount = 0; + $terminalPathCount = 0; + $questionIds = []; + $pathSampleLimit = $this->pathLimit($payload['path_sample_limit'] ?? null); + $paths = []; + $scenarioCount = max(1, count($vehicleTypeIds)); + $confirmationRows = $this->loadPathConfirmationRows($departmentId, $versionId, $laneId, $vehicleTypeId, $configSource); + + foreach ($vehicleTypeIds as $scenarioIndex => $scenarioVehicleTypeId) { + $remainingStates = $maxStates === null ? null : $maxStates - $stateCount; + if ($remainingStates !== null && $remainingStates <= 0) { + $truncated = true; + break; + } + + $scenarioScope = [ + 'department_id' => $departmentId, + 'department' => $this->labelFor('departments', $departmentId, $lookups), + 'lane_id' => $laneId, + 'lane' => $this->labelFor('lanes', $laneId, $lookups), + 'vehicle_type_id' => $scenarioVehicleTypeId, + 'vehicle_type' => $scenarioVehicleTypeId === null ? 'Auto' : $this->labelFor('vehicle_types', $scenarioVehicleTypeId, $lookups), + 'registration' => $reg, + 'customer_number' => $customerNumber, + 'config_source' => $configSource, + 'config_version_id' => $versionId, + 'hardware_mode' => $hardwareMode, + ]; + $simulate = function (array $answerOverrides) use ( + $flow, + $departmentId, + $laneId, + $reg, + $customerNumber, + $scenarioVehicleTypeId, + $configSource, + $graphConfig, + $versionId, + $includeHardware, + $hardwareMode, + $lookups, + $gatewayWorkspace, + $graph + ): array { + return $flow->previewStudioSimulation( + $departmentId, + $laneId, + $reg, + $customerNumber, + $scenarioVehicleTypeId, + [ + 'mode' => 'full_dry_run', + 'config_source' => $configSource, + 'config_payload' => $graphConfig, + 'config_version_id' => $versionId, + 'answer_overrides' => $answerOverrides, + 'include_hardware' => $includeHardware, + 'hardware_mode' => $hardwareMode, + 'lookups' => $lookups, + 'gateway_workspace' => $gatewayWorkspace, + 'graph' => $graph, + ], + ); + }; + + $projectionOptions = [ + 'scope' => $scenarioScope, + 'max_states' => $remainingStates, + 'path_sample_limit' => $pathSampleLimit === null ? null : max(0, $pathSampleLimit - count($paths)), + 'progress_callback' => function (array $projection) use ( + $progressCallback, + &$outcomes, + &$paths, + &$stateCount, + &$terminalPathCount, + &$questionIds, + $maxStates, + $pathSampleLimit, + $scenarioIndex, + $scenarioCount, + $departmentId, + $lookups, + $laneId, + $vehicleTypeId, + $reg, + $customerNumber, + $configSource, + $versionId, + $hardwareMode + ): void { + if ($progressCallback === null) { + return; + } + + $partialOutcomes = array_merge($outcomes, array_values((array)($projection['outcomes'] ?? []))); + $partialPaths = array_merge($paths, array_values((array)($projection['paths'] ?? []))); + if ($pathSampleLimit !== null && count($partialPaths) > $pathSampleLimit) { + $partialPaths = array_slice($partialPaths, 0, $pathSampleLimit); + } + + $partialQuestionIds = $questionIds; + foreach ((array)($projection['summary']['question_ids'] ?? []) as $questionId) { + $partialQuestionIds[(int)$questionId] = true; + } + + $projectionProgress = is_array($projection['progress'] ?? null) ? (array)$projection['progress'] : []; + $scenarioPercent = (float)($projectionProgress['percent'] ?? 0); + $overallPercent = min(99.0, (($scenarioIndex + ($scenarioPercent / 100)) / $scenarioCount) * 100); + + $partialPayload = $this->pathOutcomesPayload( + [ + 'department_id' => $departmentId, + 'department' => $this->labelFor('departments', $departmentId, $lookups), + 'lane_id' => $laneId, + 'lane' => $this->labelFor('lanes', $laneId, $lookups), + 'vehicle_type_id' => $vehicleTypeId, + 'vehicle_type' => $vehicleTypeId === null ? 'All current vehicle types' : $this->labelFor('vehicle_types', $vehicleTypeId, $lookups), + 'vehicle_type_count' => $scenarioCount, + 'registration' => $reg, + 'customer_number' => $customerNumber, + 'config_source' => $configSource, + 'config_version_id' => $versionId, + 'hardware_mode' => $hardwareMode, + 'max_states' => $maxStates, + ], + $partialOutcomes, + $partialPaths, + [], + false, + $maxStates, + $stateCount + (int)($projection['summary']['state_count'] ?? 0), + $terminalPathCount + (int)($projection['summary']['terminal_path_count'] ?? 0), + $partialQuestionIds, + [ + 'complete' => false, + 'percent' => (int)floor($overallPercent), + 'state_count' => $stateCount + (int)($projection['summary']['state_count'] ?? 0), + 'pending_state_count' => (int)($projectionProgress['pending_state_count'] ?? 0), + 'terminal_path_count' => $terminalPathCount + (int)($projection['summary']['terminal_path_count'] ?? 0), + 'scenario_index' => $scenarioIndex + 1, + 'scenario_count' => $scenarioCount, + ], + $confirmationRows + ); + $progressCallback($partialPayload); + }, + 'confirmation_rows' => $confirmationRows, + ]; + if ($remainingStates === null) { + unset($projectionOptions['max_states']); + } + if ($pathSampleLimit === null) { + unset($projectionOptions['path_sample_limit']); + } + $projection = $this->projectPathOutcomesFromSimulator($simulate, $projectionOptions); + foreach ((array)($projection['outcomes'] ?? []) as $outcome) { + if (is_array($outcome)) { + $outcomes[] = $outcome; + } + } + foreach ((array)($projection['paths'] ?? []) as $path) { + if (!is_array($path)) { + continue; + } + if ($pathSampleLimit === null || count($paths) < $pathSampleLimit) { + $paths[] = $path; + } + } + foreach ((array)($projection['warnings'] ?? []) as $warning) { + $warnings[] = (string)$warning; + } + $truncated = $truncated || (bool)($projection['truncated'] ?? false); + $stateCount += (int)($projection['summary']['state_count'] ?? 0); + $terminalPathCount += (int)($projection['summary']['terminal_path_count'] ?? 0); + foreach ((array)($projection['summary']['question_ids'] ?? []) as $questionId) { + $questionIds[(int)$questionId] = true; + } + } + + if ($truncated) { + $warnings[] = 'Path projection was truncated at ' . $maxStates . ' explored state(s). Narrow the lane or vehicle type filters to inspect more paths.'; + } + + return $this->pathOutcomesPayload( + [ + 'department_id' => $departmentId, + 'department' => $this->labelFor('departments', $departmentId, $lookups), + 'lane_id' => $laneId, + 'lane' => $this->labelFor('lanes', $laneId, $lookups), + 'vehicle_type_id' => $vehicleTypeId, + 'vehicle_type' => $vehicleTypeId === null ? 'All current vehicle types' : $this->labelFor('vehicle_types', $vehicleTypeId, $lookups), + 'vehicle_type_count' => count($vehicleTypeIds), + 'registration' => $reg, + 'customer_number' => $customerNumber, + 'config_source' => $configSource, + 'config_version_id' => $versionId, + 'hardware_mode' => $hardwareMode, + 'max_states' => $maxStates, + ], + $outcomes, + $paths, + $warnings, + $truncated, + $maxStates, + $stateCount, + $terminalPathCount, + $questionIds, + [ + 'complete' => true, + 'percent' => 100, + 'state_count' => $stateCount, + 'pending_state_count' => 0, + 'terminal_path_count' => $terminalPathCount, + 'scenario_index' => $scenarioCount, + 'scenario_count' => $scenarioCount, + ], + $confirmationRows + ); + } + + /** + * @param callable(array):array $simulate + * @param array $options + * @return array + */ + public function projectPathOutcomesFromSimulator(callable $simulate, array $options = []): array + { + $maxStates = $this->pathLimit($options['max_states'] ?? null); + $sampleLimit = max(1, min(10, (int)($options['sample_limit'] ?? 5))); + $pathSampleLimit = $this->pathLimit($options['path_sample_limit'] ?? null); + $progressCallback = is_callable($options['progress_callback'] ?? null) ? $options['progress_callback'] : null; + $progressIntervalStates = max(1, (int)($options['progress_interval_states'] ?? 128)); + $scope = is_array($options['scope'] ?? null) ? (array)$options['scope'] : []; + $confirmationRows = is_array($options['confirmation_rows'] ?? null) ? (array)$options['confirmation_rows'] : []; + $stack = [[ + 'answers' => [], + 'chain' => [], + ]]; + $seenStates = []; + $groups = []; + $paths = []; + $stateCount = 0; + $terminalPathCount = 0; + $questionIds = []; + $truncated = false; + + while ($stack !== []) { + if ($maxStates !== null && $stateCount >= $maxStates) { + $truncated = true; + break; + } + + $state = array_pop($stack); + $answers = is_array($state['answers'] ?? null) ? (array)$state['answers'] : []; + ksort($answers, SORT_NUMERIC); + $stateKey = $this->stableJson($answers); + if (isset($seenStates[$stateKey])) { + continue; + } + $seenStates[$stateKey] = true; + $stateCount++; + + $simulation = $simulate($this->pathAnswerOverrides($answers)); + $nextQuestion = $this->nextPathQuestion($simulation, $answers); + if ($nextQuestion !== null) { + $questionId = (int)($nextQuestion['id'] ?? 0); + if ($questionId > 0) { + $questionIds[$questionId] = true; + foreach ([false, true] as $answerValue) { + $nextAnswers = $answers; + $nextAnswers[$questionId] = $answerValue; + ksort($nextAnswers, SORT_NUMERIC); + $nextChain = is_array($state['chain'] ?? null) ? array_values((array)$state['chain']) : []; + $nextChain[] = [ + 'question_id' => $questionId, + 'question' => (string)($nextQuestion['label'] ?? $nextQuestion['question'] ?? ('Question ' . $questionId)), + 'node_id' => (string)($nextQuestion['node_id'] ?? ('question:' . $questionId)), + 'answer' => $answerValue, + 'answer_label' => $answerValue ? 'Yes' : 'No', + ]; + $stack[] = [ + 'answers' => $nextAnswers, + 'chain' => $nextChain, + ]; + } + } + if ($progressCallback !== null && $stateCount % $progressIntervalStates === 0) { + $progressCallback($this->pathOutcomesProjectionPayload( + $scope, + $groups, + $paths, + $truncated, + $maxStates, + $stateCount, + $terminalPathCount, + $questionIds, + count($stack), + $confirmationRows + )); + } + continue; + } + + $terminalPathCount++; + $chain = is_array($state['chain'] ?? null) ? (array)$state['chain'] : []; + $this->addPathOutcomeGroup($groups, $simulation, $chain, $scope, $sampleLimit); + if ($pathSampleLimit === null || count($paths) < $pathSampleLimit) { + $paths[] = $this->pathResultFromSimulation($simulation, $chain, $scope); + } + + if ($progressCallback !== null && $stateCount % $progressIntervalStates === 0) { + $progressCallback($this->pathOutcomesProjectionPayload( + $scope, + $groups, + $paths, + $truncated, + $maxStates, + $stateCount, + $terminalPathCount, + $questionIds, + count($stack), + $confirmationRows + )); + } + } + + return $this->pathOutcomesProjectionPayload( + $scope, + $groups, + $paths, + $truncated, + $maxStates, + $stateCount, + $terminalPathCount, + $questionIds, + count($stack), + $confirmationRows + ); + } + + /** + * @param array $payload + * @return array + */ + public function confirmPathOutcome(int $departmentId, array $payload, ?int $userId): array + { + $pathSignature = trim((string)($payload['path_signature'] ?? '')); + $resultSignature = trim((string)($payload['result_signature'] ?? '')); + if ($pathSignature === '' || $resultSignature === '') { + throw new \RuntimeException('path_signature and result_signature are required.'); + } + + $scope = is_array($payload['scope'] ?? null) ? (array)$payload['scope'] : []; + $laneId = $this->nullableInt($payload['lane_id'] ?? $scope['lane_id'] ?? null); + $vehicleTypeId = $this->nullableInt($payload['vehicle_type_id'] ?? $scope['vehicle_type_id'] ?? null); + $configVersionId = $this->nullableInt($payload['config_version_id'] ?? $scope['config_version_id'] ?? null); + $configSource = strtolower(trim((string)($payload['config_source'] ?? $scope['config_source'] ?? 'draft'))); + if ($configSource === '') { + $configSource = 'draft'; + } + $answers = is_array($payload['answers'] ?? null) ? array_values((array)$payload['answers']) : []; + $result = is_array($payload['result'] ?? null) ? (array)$payload['result'] : []; + + $answersJson = json_encode($this->sortStableValue($answers), JSON_UNESCAPED_UNICODE); + $resultJson = json_encode($this->sortStableValue($result), JSON_UNESCAPED_UNICODE); + $scopeJson = json_encode($this->sortStableValue($scope), JSON_UNESCAPED_UNICODE); + if ($answersJson === false || $resultJson === false || $scopeJson === false) { + throw new \RuntimeException('Could not encode path confirmation payload.'); + } + + $pdo = db::getPDO(); + $select = $pdo->prepare( + "SELECT id + FROM department_selfserve_path_confirmations + WHERE department_id = :department_id + AND path_signature = :path_signature + AND " . ($configVersionId === null ? "config_version_id IS NULL" : "config_version_id = :config_version_id") . " + AND deleted_at IS NULL + ORDER BY id DESC + LIMIT 1" + ); + $selectParams = [ + ':department_id' => $departmentId, + ':path_signature' => $pathSignature, + ]; + if ($configVersionId !== null) { + $selectParams[':config_version_id'] = $configVersionId; + } + $select->execute($selectParams); + $row = $select->fetch(\PDO::FETCH_ASSOC); + + if (is_array($row) && (int)($row['id'] ?? 0) > 0) { + $update = $pdo->prepare( + "UPDATE department_selfserve_path_confirmations + SET lane_id = :lane_id, + vehicle_type_id = :vehicle_type_id, + config_source = :config_source, + result_signature = :result_signature, + answers_json = :answers_json, + result_json = :result_json, + scope_json = :scope_json, + confirmed_by = :confirmed_by, + confirmed_at = NOW(), + stale_reason = NULL, + deleted_at = NULL + WHERE id = :id" + ); + $update->execute([ + ':lane_id' => $laneId, + ':vehicle_type_id' => $vehicleTypeId, + ':config_source' => $configSource, + ':result_signature' => $resultSignature, + ':answers_json' => $answersJson, + ':result_json' => $resultJson, + ':scope_json' => $scopeJson, + ':confirmed_by' => $userId, + ':id' => (int)$row['id'], + ]); + $id = (int)$row['id']; + } else { + $insert = $pdo->prepare( + "INSERT INTO department_selfserve_path_confirmations + (department_id, lane_id, vehicle_type_id, config_version_id, config_source, path_signature, result_signature, answers_json, result_json, scope_json, confirmed_by, confirmed_at) + VALUES + (:department_id, :lane_id, :vehicle_type_id, :config_version_id, :config_source, :path_signature, :result_signature, :answers_json, :result_json, :scope_json, :confirmed_by, NOW())" + ); + $insert->execute([ + ':department_id' => $departmentId, + ':lane_id' => $laneId, + ':vehicle_type_id' => $vehicleTypeId, + ':config_version_id' => $configVersionId, + ':config_source' => $configSource, + ':path_signature' => $pathSignature, + ':result_signature' => $resultSignature, + ':answers_json' => $answersJson, + ':result_json' => $resultJson, + ':scope_json' => $scopeJson, + ':confirmed_by' => $userId, + ]); + $id = (int)$pdo->lastInsertId(); + } + + return [ + 'id' => $id, + 'department_id' => $departmentId, + 'lane_id' => $laneId, + 'vehicle_type_id' => $vehicleTypeId, + 'config_version_id' => $configVersionId, + 'config_source' => $configSource, + 'path_signature' => $pathSignature, + 'result_signature' => $resultSignature, + 'answers' => $answers, + 'result' => $result, + 'scope' => $scope, + 'confirmation_status' => 'confirmed', + ]; + } + + /** + * @param array $payload + * @return array + */ + public function resetPathConfirmation(int $departmentId, array $payload): array + { + $pathSignature = trim((string)($payload['path_signature'] ?? '')); + if ($pathSignature === '') { + throw new \RuntimeException('path_signature is required.'); + } + $scope = is_array($payload['scope'] ?? null) ? (array)$payload['scope'] : []; + $configVersionId = $this->nullableInt($payload['config_version_id'] ?? $scope['config_version_id'] ?? null); + $pdo = db::getPDO(); + $statement = $pdo->prepare( + "UPDATE department_selfserve_path_confirmations + SET deleted_at = NOW() + WHERE department_id = :department_id + AND path_signature = :path_signature + AND " . ($configVersionId === null ? "config_version_id IS NULL" : "config_version_id = :config_version_id") . " + AND deleted_at IS NULL" + ); + $params = [ + ':department_id' => $departmentId, + ':path_signature' => $pathSignature, + ]; + if ($configVersionId !== null) { + $params[':config_version_id'] = $configVersionId; + } + $statement->execute($params); + + return [ + 'path_signature' => $pathSignature, + 'reset' => true, + 'affected' => $statement->rowCount(), + ]; + } + + /** + * @param array $payload + * @return array + */ + public function runGatewayAction(int $departmentId, int $gatewayId, string $action, array $payload, ?int $userId): array + { + if (!class_exists(edge_gateway_view_service::class)) { + throw new \RuntimeException('Edge gateway module is not available.'); + } + + $gateway = (new edge_gateway_view_service())->getGateway($gatewayId); + if (!isset($gateway['id'])) { + throw new \RuntimeException('Edge gateway not found.'); + } + if ((int)($gateway['department_id'] ?? 0) !== $departmentId) { + throw new \RuntimeException('Edge gateway does not belong to the selected department.'); + } + + $action = strtolower(trim($action)); + $operations = new edge_gateway_operation_service(); + return match ($action) { + 'discovery', 'discover' => [ + 'action' => 'discovery', + 'operation' => $operations->queueDiscoveryOperation($gatewayId, $userId), + ], + 'update' => [ + 'action' => 'update', + 'operation' => $operations->queueOperation($gatewayId, edge_gateway_operation_service::TYPE_UPDATE, (array)($payload['request'] ?? []), $userId), + ], + 'uninstall' => [ + 'action' => 'uninstall', + 'operation' => $operations->queueOperation($gatewayId, edge_gateway_operation_service::TYPE_UNINSTALL, (array)($payload['request'] ?? []), $userId), + ], + 'cancel' => [ + 'action' => 'cancel', + 'operation' => $operations->cancelOperation($gatewayId, (int)($payload['operation_id'] ?? 0), $userId), + ], + 'rotate_credentials' => [ + 'action' => 'rotate_credentials', + 'gateway' => $operations->rotateCredentials($gatewayId, $userId), + ], + 'bindings' => [ + 'action' => 'bindings', + 'gateway' => (new edge_gateway_registry_service())->setRelayBindings($gatewayId, (array)($payload['bindings'] ?? []), $userId), + ], + default => throw new \RuntimeException('Unsupported gateway action: ' . $action), + }; + } + + /** + * @param array $payload + * @param array $permissions + * @return array + */ + public function applyVirtualHardwareOperation(int $departmentId, array $payload, ?int $userId, array $permissions = []): array + { + if (($permissions['can_edit'] ?? false) !== true) { + throw new \RuntimeException('You do not have permission to edit self-serve studio hardware.'); + } + + $operation = strtolower(trim((string)($payload['operation'] ?? $payload['action'] ?? ''))); + $data = is_array($payload['data'] ?? null) ? (array)$payload['data'] : $payload; + $realWorkspace = $this->buildGatewayWorkspace($departmentId, false); + (new selfserve_virtual_hardware())->applyOperation($departmentId, $operation, $data, $userId, $realWorkspace); + + return $this->buildGraph($departmentId, $userId, $permissions); + } + + /** + * @return array + */ + private function buildGatewayWorkspace(int $departmentId, bool $includeVirtual = true): array + { + if (!class_exists(edge_gateway_department_workspace_service::class)) { + $workspace = [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [], + 'issues' => [], + 'actions' => [], + 'available' => false, + ]; + return $includeVirtual ? (new selfserve_virtual_hardware())->mergeWorkspace($workspace, $departmentId) : $workspace; + } + + try { + $workspace = (new edge_gateway_department_workspace_service())->getDepartmentWorkspace($departmentId); + } catch (\Throwable $exception) { + $workspace = [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [], + 'issues' => [ + [ + 'severity' => 'warning', + 'message' => $exception->getMessage(), + ], + ], + 'actions' => [], + 'available' => false, + ]; + } + + return $includeVirtual ? (new selfserve_virtual_hardware())->mergeWorkspace($workspace, $departmentId) : $workspace; + } + + /** + * @param array $config + * @param array $gatewayWorkspace + * @return array + */ + private function buildLookups(int $departmentId, array $config, array $gatewayWorkspace): array + { + $departmentRows = $this->fetchRows('departments', ['id', 'name', 'description'], ['id' => $departmentId]); + $laneRows = $this->fetchRows('department_lanes', [ + 'id', + 'department', + 'name', + 'relay_in_id', + 'relay_out_id', + 'relay_machine_id', + 'relay_machine_program_picker_id', + 'relay_machine_cleaner_id', + 'machine_type_id', + 'dynamic_image_id', + 'selfserve_enabled', + ], ['department' => $departmentId]); + $machineTypeRows = $this->fetchRows('selfserve_machine_types', ['id', 'name', 'description'], []); + $productRows = $this->fetchRows('products', ['id', 'name', 'description', 'price', 'subscription_allowed', 'category', 'piktogram', 'is_wash', 'order_priority'], []); + $vehicleTypeRows = $this->vehicleTypeRowsFromProducts($productRows); + $users = $this->fetchRows('users', ['id', 'customer_number', 'display_name'], []); + $laneLookupRows = $this->labelRows($laneRows, 'name'); + $machineTypeLookupRows = $this->addReferencedMachineTypeRows($this->labelRows($machineTypeRows, 'name'), $laneRows, $config); + + $lookups = [ + 'departments' => $this->labelRows($departmentRows, 'name'), + 'lanes' => $laneLookupRows, + 'products' => $this->labelRows($productRows, 'name'), + 'machine_types' => $machineTypeLookupRows, + 'dynamic_images' => $this->dynamicImageRowsFromLanes($laneRows), + 'vehicle_types' => $vehicleTypeRows, + 'questions' => $this->configLabelRows((array)($config['questions'] ?? []), 'question'), + 'conditions' => $this->configLabelRows((array)($config['conditions'] ?? []), 'name'), + 'rules' => $this->configLabelRows((array)($config['rules'] ?? []), 'name'), + 'tasks' => $this->configLabelRows((array)($config['tasks'] ?? []), 'task'), + 'actions' => $this->configLabelRows((array)($config['actions'] ?? []), 'name'), + 'gateways' => $this->gatewayLabelRows((array)($gatewayWorkspace['gateways'] ?? [])), + 'relays' => $this->relayLabelRows((array)($gatewayWorkspace['relays'] ?? [])), + 'bindings' => $this->bindingLabelRows((array)($gatewayWorkspace['gateways'] ?? [])), + 'users' => array_map(static function (array $row): array { + $label = trim((string)($row['display_name'] ?? '')); + if ($label === '') { + $label = 'User ' . (string)($row['customer_number'] ?? $row['id'] ?? ''); + } + $row['label'] = $label; + return $row; + }, $users), + ]; + + $labels = []; + foreach ($lookups as $type => $rows) { + if (!is_array($rows)) { + continue; + } + $labels[$type] = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $id = (string)($row['id'] ?? ''); + if ($id !== '') { + $labels[$type][$id] = (string)($row['label'] ?? $id); + } + } + } + $lookups['labels'] = $labels; + + return $lookups; + } + + /** + * @param array> $rows + * @param array> $laneRows + * @param array $config + * @return array> + */ + private function addReferencedMachineTypeRows(array $rows, array $laneRows, array $config): array + { + $rowsById = []; + foreach ($rows as $row) { + $id = $this->nullableInt($row['id'] ?? null); + if ($id === null) { + continue; + } + $row['id'] = $id; + $row['label'] = trim((string)($row['label'] ?? $row['name'] ?? '')) ?: 'Machine type ' . $id; + $rowsById[$id] = $row; + } + + foreach ($this->referencedMachineTypeIds($laneRows, $config) as $id) { + if (!isset($rowsById[$id])) { + $rowsById[$id] = [ + 'id' => $id, + 'name' => 'Machine type ' . $id, + 'label' => 'Machine type ' . $id, + 'referenced' => true, + ]; + } + } + + ksort($rowsById, SORT_NUMERIC); + return array_values($rowsById); + } + + /** + * @param array> $laneRows + * @param array $config + * @return array + */ + private function referencedMachineTypeIds(array $laneRows, array $config): array + { + $ids = []; + foreach ($laneRows as $row) { + $id = $this->nullableInt($row['machine_type_id'] ?? null); + if ($id !== null) { + $ids[$id] = $id; + } + } + + foreach (['conditions', 'questions', 'tasks', 'actions'] as $section) { + foreach ((array)($config[$section] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + $id = $this->nullableInt($row['machine_type_id'] ?? null); + if ($id !== null) { + $ids[$id] = $id; + } + } + } + + ksort($ids, SORT_NUMERIC); + return array_values($ids); + } + + /** + * @param array> $laneRows + * @return array> + */ + private function dynamicImageRowsFromLanes(array $laneRows): array + { + $rowsById = []; + foreach ($this->supportedDynamicImageRows() as $row) { + $id = $this->nullableInt($row['id'] ?? null); + if ($id !== null) { + $rowsById[$id] = $row; + } + } + + foreach ($laneRows as $lane) { + $id = $this->nullableInt($lane['dynamic_image_id'] ?? null); + if ($id === null || isset($rowsById[$id])) { + continue; + } + $rowsById[$id] = [ + 'id' => $id, + 'name' => 'Dynamic image ' . $id, + 'label' => 'Dynamic image ' . $id, + 'referenced' => true, + ]; + } + + ksort($rowsById, SORT_NUMERIC); + return array_values($rowsById); + } + + /** + * @return array> + */ + private function supportedDynamicImageRows(): array + { + return [ + [ + 'id' => 1, + 'name' => 'Machine 1', + 'label' => 'Machine 1', + 'class' => 'dynamicimages\\images\\machine_1', + ], + ]; + } + + /** + * @param array> $nodes + * @param array> $edges + * @param array $workspace + */ + private function appendGatewayNodesAndEdges(array &$nodes, array &$edges, array $workspace): void + { + $relayServices = $this->relayServicesFromWorkspace($workspace); + + foreach ((array)($workspace['gateways'] ?? []) as $index => $gateway) { + if (!is_array($gateway)) { + continue; + } + $gatewayId = $this->gatewayIdentifier($gateway); + if ($gatewayId === '') { + continue; + } + + $nodeId = $this->gatewayNodeId($gateway); + $nodes[] = $this->node($nodeId, 'default', (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)), 'edge_gateway', [ + 'object_id' => $gatewayId, + 'raw' => $gateway, + 'subtitle' => (string)($gateway['status'] ?? 'UNKNOWN'), + ], 1440, 160 + ($index * 150)); + + foreach ((array)($gateway['bindings'] ?? []) as $bindingIndex => $binding) { + if (!is_array($binding)) { + continue; + } + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + $bindingServices = $this->bindingServices($binding, $relayId, $relayServices); + if ($bindingServices !== []) { + $binding['services'] = $bindingServices; + if (trim((string)($binding['role'] ?? '')) === '' && count($bindingServices) === 1) { + $binding['role'] = $bindingServices[0]; + } + } + $bindingId = $this->bindingNodeId($gatewayId, $relayId, (int)$bindingIndex, $binding); + $nodes[] = $this->node($bindingId, 'default', (string)($binding['label'] ?? ('Relay ' . $relayId)), 'relay_binding', [ + 'object_id' => $relayId, + 'raw' => $binding, + 'subtitle' => (string)($binding['role'] ?? 'Relay binding'), + ], 1720, 180 + (($index * 4 + $bindingIndex) * 100)); + $edges[] = $this->edge('gateway-binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex, $nodeId, $bindingId, 'gateway_binding', 'binds'); + $edges[] = $this->edge('binding-relay:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex, $bindingId, 'relay:' . $relayId, 'relay_binding', 'controls'); + } + } + + $relayIndex = 0; + foreach ((array)($workspace['relays'] ?? []) as $relay) { + if (!is_array($relay)) { + continue; + } + $relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? '')); + if ($relayId === '') { + continue; + } + $nodes[] = $this->node('relay:' . $relayId, 'default', (string)($relay['name'] ?? ('Relay ' . $relayId)), 'relay', [ + 'object_id' => $relayId, + 'raw' => $relay, + 'subtitle' => 'Hardware relay', + ], 2020, 180 + ($relayIndex * 100)); + $relayIndex++; + } + + foreach ((array)($workspace['lanes'] ?? []) as $lane) { + if (!is_array($lane)) { + continue; + } + $laneId = (int)($lane['id'] ?? 0); + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (!is_array($slot)) { + continue; + } + $relayId = trim((string)($slot['relay_id'] ?? '')); + if ($laneId > 0 && $relayId !== '') { + $edges[] = $this->edge('relay-lane:' . $relayId . ':' . $laneId . ':' . (string)($slot['slot'] ?? ''), 'relay:' . $relayId, 'lane:' . $laneId, 'lane_relay', (string)($slot['slot'] ?? 'relay')); + } + } + } + } + + /** + * @param array> $edges + * @param array> $tasks + * @param array $workspace + */ + private function appendTaskServiceEdges(array &$edges, array $tasks, array $workspace): void + { + $bindingsByService = []; + foreach ($this->gatewayBindingReferences($workspace) as $binding) { + foreach ((array)$binding['services'] as $service) { + $bindingsByService[$service][] = $binding; + } + } + + foreach ($tasks as $task) { + $taskId = (int)($task['id'] ?? 0); + if ($taskId <= 0) { + continue; + } + foreach ($this->normalizeServiceList($task['services'] ?? []) as $service) { + foreach ($bindingsByService[$service] ?? [] as $binding) { + $edges[] = $this->edge( + 'task-service:' . $taskId . ':' . $service . ':' . $binding['gateway_id'] . ':' . $binding['relay_id'] . ':' . $binding['binding_index'], + 'task:' . $taskId, + (string)$binding['node_id'], + 'task_service', + $service + ); + } + } + } + } + + /** + * @param array> $edges + * @param array> $actions + * @param array $workspace + */ + private function appendActionRelayEdges(array &$edges, array $actions, array $workspace): void + { + foreach ($actions as $action) { + if (!is_array($action)) { + continue; + } + $normalizedAction = selfserve_studio_actions::normalize($action); + $actionId = (int)($normalizedAction['id'] ?? 0); + if ($actionId <= 0) { + continue; + } + + $relayRole = $this->normalizeServiceName(selfserve_studio_actions::relayRoleForOperation((string)$normalizedAction['operation'])); + if ($relayRole === '' || $relayRole === 'ACTION') { + continue; + } + + $laneId = (int)($normalizedAction['lane'] ?? 0); + foreach ($this->actionRelayTargets($workspace, $relayRole, $laneId) as $target) { + $edges[] = $this->edge( + 'action-relay:' . $actionId . ':' . $target['relay_id'] . ':' . $relayRole . ':' . $target['lane_id'], + 'action:' . $actionId, + 'relay:' . $target['relay_id'], + 'action_relay', + $relayRole + ); + } + } + } + + /** + * @param array $workspace + * @return array + */ + private function actionRelayTargets(array $workspace, string $relayRole, int $actionLaneId): array + { + $targets = []; + $seen = []; + foreach ((array)($workspace['lanes'] ?? []) as $lane) { + if (!is_array($lane)) { + continue; + } + $laneId = (int)($lane['id'] ?? 0); + if ($laneId <= 0 || ($actionLaneId > 0 && $laneId !== $actionLaneId)) { + continue; + } + + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (!is_array($slot)) { + continue; + } + $slotRole = $this->normalizeServiceName($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? ''); + $relayId = trim((string)($slot['relay_id'] ?? '')); + if ($relayId === '' || $slotRole !== $relayRole) { + continue; + } + + $key = $laneId . ':' . $relayRole . ':' . $relayId; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $targets[] = [ + 'relay_id' => $relayId, + 'lane_id' => $laneId, + ]; + } + } + + return $targets; + } + + /** + * @param array $config + * @param array $operation + */ + private function applyConfigOperation(int $departmentId, array &$config, array $operation, array $permissions = []): void + { + $action = strtolower((string)($operation['action'] ?? '')); + $entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? '')); + $data = is_array($operation['data'] ?? null) ? (array)$operation['data'] : []; + $id = (int)($operation['id'] ?? $data['id'] ?? 0); + + if ($action === 'connect') { + $this->applyConfigConnection($departmentId, $config, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), false); + return; + } + if ($action === 'disconnect') { + $this->applyConfigConnection($departmentId, $config, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), true); + return; + } + if ($action === 'reorder') { + $this->applyConfigReorder($config, $entity, (array)($operation['items'] ?? [])); + return; + } + if ($action === 'upsert_path' || ($entity === 'path' && $action === 'upsert')) { + $this->upsertConfigPath($departmentId, $config, $data); + return; + } + if ($entity === '') { + throw new \RuntimeException('Studio graph operation is missing entity.'); + } + if ($entity === 'lane') { + $this->applyLaneOperation($departmentId, $action, $id, $data, $permissions); + return; + } + if ($entity === 'rule') { + throw new \RuntimeException('Standalone rule operations are not supported in self-serve rules v2.'); + } + + if ($action === 'create') { + $this->createConfigEntity($departmentId, $config, $entity, $data); + return; + } + if ($id <= 0) { + throw new \RuntimeException('Studio graph operation is missing id.'); + } + if ($action === 'update') { + $this->updateConfigEntity($departmentId, $config, $entity, $id, $data); + return; + } + if ($action === 'delete') { + $this->deleteConfigEntity($config, $entity, $id); + return; + } + + throw new \RuntimeException('Unsupported studio graph operation: ' . $action); + } + + /** + * @param array $config + * @param array $data + */ + private function upsertConfigPath(int $departmentId, array &$config, array $data): void + { + $scope = $this->normalizePathEditorScope($departmentId, is_array($data['scope'] ?? null) ? (array)$data['scope'] : $data); + $answers = $this->normalizePathEditorAnswers($data['answers'] ?? []); + if ($answers === []) { + throw new \RuntimeException('Path editor operation requires at least one answer.'); + } + $this->assertPathEditorQuestionsExist($config, $answers); + + $result = $this->normalizePathEditorResult(is_array($data['result'] ?? null) ? (array)$data['result'] : $data); + $previousPathKey = trim((string)($data['previous_path_key'] ?? '')); + $pathKey = trim((string)($data['path_key'] ?? '')); + if ($pathKey === '') { + $pathKey = $previousPathKey !== '' ? $previousPathKey : $this->pathEditorPathKey($scope, $answers); + } + + if (!isset($config['v2_meta']) || !is_array($config['v2_meta'])) { + $config['v2_meta'] = []; + } + if (!isset($config['v2_meta']['path_editor']) || !is_array($config['v2_meta']['path_editor'])) { + $config['v2_meta']['path_editor'] = []; + } + if (!isset($config['v2_meta']['path_editor']['paths']) || !is_array($config['v2_meta']['path_editor']['paths'])) { + $config['v2_meta']['path_editor']['paths'] = []; + } + if ($previousPathKey !== '' && $previousPathKey !== $pathKey && isset($config['v2_meta']['path_editor']['paths'][$previousPathKey])) { + $config['v2_meta']['path_editor']['paths'][$pathKey] = $config['v2_meta']['path_editor']['paths'][$previousPathKey]; + unset($config['v2_meta']['path_editor']['paths'][$previousPathKey]); + } + + $paths = &$config['v2_meta']['path_editor']['paths']; + $existing = is_array($paths[$pathKey] ?? null) ? (array)$paths[$pathKey] : []; + $conditionId = $this->nullableInt($existing['condition_id'] ?? $data['condition_id'] ?? $data['existing_condition_id'] ?? null); + $existingTaskIds = $this->pathEditorExistingTaskIds($existing, $data); + $conditionId = $this->upsertPathEditorCondition($departmentId, $config, $pathKey, $scope, $answers, $result, $conditionId); + + $taskIds = []; + if ((bool)$result['machine_allowed']) { + $baseOrderPriority = $this->pathEditorTaskBaseOrderPriority($config, $existingTaskIds); + foreach (array_values((array)($result['tasks'] ?? [])) as $index => $taskResult) { + if (!is_array($taskResult)) { + continue; + } + $taskIds[] = $this->upsertPathEditorTask( + $departmentId, + $config, + $pathKey, + $scope, + $taskResult, + $conditionId, + $existingTaskIds[$index] ?? null, + $baseOrderPriority + ($index * 10) + ); + } + foreach (array_slice($existingTaskIds, count($taskIds)) as $staleTaskId) { + $this->deleteConfigEntity($config, 'task', $staleTaskId); + } + } else { + foreach ($existingTaskIds as $staleTaskId) { + $this->deleteConfigEntity($config, 'task', $staleTaskId); + } + } + + $taskId = $taskIds[0] ?? null; + $pathSignature = $this->pathSignature($scope, $answers); + $resultSignature = $this->pathEditorResultSignature($result, $taskIds); + $paths[$pathKey] = [ + 'path_key' => $pathKey, + 'condition_id' => $conditionId, + 'task_id' => $taskId, + 'task_ids' => $taskIds, + 'scope' => $scope, + 'answers' => $answers, + 'result' => $result, + 'path_signature' => $pathSignature, + 'result_signature' => $resultSignature, + 'updated_at' => date('c'), + ]; + unset($paths); + } + + /** + * @param array $scope + * @return array + */ + private function normalizePathEditorScope(int $departmentId, array $scope): array + { + return [ + 'department_id' => $departmentId, + 'lane_id' => $this->nullableInt($scope['lane_id'] ?? $scope['lane'] ?? null), + 'vehicle_type_id' => $this->nullableInt($scope['vehicle_type_id'] ?? $scope['product'] ?? $scope['product_id'] ?? null), + 'machine_type_id' => $this->nullableInt($scope['machine_type_id'] ?? null), + 'config_source' => strtolower(trim((string)($scope['config_source'] ?? 'draft'))) ?: 'draft', + 'hardware_mode' => strtolower(trim((string)($scope['hardware_mode'] ?? 'studio'))) ?: 'studio', + ]; + } + + /** + * @return array + */ + private function normalizePathEditorAnswers(mixed $answers): array + { + $rows = []; + if (!is_array($answers)) { + return $rows; + } + + foreach ($answers as $key => $entry) { + if (is_array($entry)) { + $questionId = (int)($entry['question_id'] ?? $entry['id'] ?? $key); + $rawValue = $entry['value'] ?? $entry['answer'] ?? null; + } else { + $questionId = (int)$key; + $rawValue = $entry; + } + if ($questionId <= 0) { + continue; + } + $value = filter_var($rawValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($value === null) { + continue; + } + $rows[] = [ + 'question_id' => $questionId, + 'value' => (bool)$value, + 'answer' => (bool)$value, + 'answer_label' => (bool)$value ? 'Yes' : 'No', + ]; + } + + return $rows; + } + + /** + * @param array $config + * @param array $answers + */ + private function assertPathEditorQuestionsExist(array $config, array $answers): void + { + $questionIds = []; + foreach ((array)($config['questions'] ?? []) as $question) { + if (is_array($question)) { + $questionIds[(int)($question['id'] ?? 0)] = true; + } + } + foreach ($answers as $answer) { + $questionId = (int)($answer['question_id'] ?? 0); + if ($questionId > 0 && !isset($questionIds[$questionId])) { + throw new \RuntimeException('Path editor answer references unknown question ' . $questionId . '.'); + } + } + } + + /** + * @param array $result + * @return array + */ + private function normalizePathEditorResult(array $result): array + { + $machineAllowed = filter_var($result['machine_allowed'] ?? $result['allowed'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + $machineAllowed = $machineAllowed !== false; + $services = $machineAllowed ? $this->normalizeServiceList($result['services'] ?? ['MACHINE']) : $this->normalizeServiceList($result['services'] ?? []); + if ($machineAllowed && !in_array('MACHINE', $services, true)) { + $services[] = 'MACHINE'; + sort($services); + } + + try { + $buttons = department_selfserve_tasks_o::normalizeButtonsInput($result['buttons'] ?? []); + } catch (\Throwable $exception) { + throw new \RuntimeException('Invalid path editor buttons: ' . $exception->getMessage()); + } + + $dynamicImagesVehicleType = $this->nullableInt($result['dynamic_images_vehicle_type'] ?? null); + $hasTaskList = array_key_exists('tasks', $result) && is_array($result['tasks']); + $tasks = []; + if ($machineAllowed) { + if ($hasTaskList) { + foreach (array_values((array)$result['tasks']) as $index => $task) { + if (!is_array($task)) { + continue; + } + $tasks[] = $this->normalizePathEditorTaskResult((array)$task, $services, $index); + } + } else { + $tasks[] = $this->normalizePathEditorTaskResult([ + 'task' => $result['task'] ?? $result['task_text'] ?? 'Start machine', + 'description' => $result['description'] ?? '', + 'services' => $services, + 'buttons' => $buttons, + 'dynamic_images_vehicle_type' => $dynamicImagesVehicleType, + ], $services, 0); + } + } + + if ($hasTaskList) { + $buttons = $this->flattenPathEditorTaskButtons($tasks); + $services = $this->mergePathEditorTaskServices($services, $tasks, $machineAllowed); + if ($dynamicImagesVehicleType === null) { + foreach ($tasks as $task) { + $dynamicImagesVehicleType = $this->nullableInt($task['dynamic_images_vehicle_type'] ?? null); + if ($dynamicImagesVehicleType !== null) { + break; + } + } + } + } + + return [ + 'machine_allowed' => $machineAllowed, + 'task' => trim((string)($result['task'] ?? $result['task_text'] ?? 'Start machine')) ?: 'Start machine', + 'description' => trim((string)($result['description'] ?? '')), + 'services' => $services, + 'buttons' => $buttons, + 'dynamic_images_vehicle_type' => $dynamicImagesVehicleType, + 'tasks' => $tasks, + 'condition_name' => trim((string)($result['condition_name'] ?? '')), + ]; + } + + /** + * @param array $task + * @param array $fallbackServices + * @return array + */ + private function normalizePathEditorTaskResult(array $task, array $fallbackServices, int $index): array + { + try { + $buttons = department_selfserve_tasks_o::normalizeButtonsInput($task['buttons'] ?? []); + } catch (\Throwable $exception) { + throw new \RuntimeException('Invalid path editor task buttons: ' . $exception->getMessage()); + } + + $services = $this->normalizeServiceList($task['services'] ?? $fallbackServices); + if (!in_array('MACHINE', $services, true)) { + $services[] = 'MACHINE'; + } + + $dynamicImagesVehicleType = $this->nullableInt($task['dynamic_images_vehicle_type'] ?? null); + if (($dynamicImagesVehicleType !== null || in_array('program_picker', $buttons, true)) && !in_array('PROGRAM_PICKER', $services, true)) { + $services[] = 'PROGRAM_PICKER'; + } + sort($services); + + return [ + 'task' => trim((string)($task['task'] ?? $task['label'] ?? ('Task ' . ($index + 1)))) ?: ('Task ' . ($index + 1)), + 'description' => trim((string)($task['description'] ?? '')), + 'services' => $services, + 'buttons' => $buttons, + 'dynamic_images_vehicle_type' => $dynamicImagesVehicleType, + ]; + } + + /** + * @param array> $tasks + * @return array + */ + private function flattenPathEditorTaskButtons(array $tasks): array + { + $buttons = []; + foreach ($tasks as $task) { + if (!is_array($task)) { + continue; + } + foreach ($this->normalizeArrayPayload($task['buttons'] ?? []) as $button) { + $buttons[] = $button; + } + } + + return department_selfserve_tasks_o::normalizeButtonsInput($buttons); + } + + /** + * @param array $services + * @param array> $tasks + * @return array + */ + private function mergePathEditorTaskServices(array $services, array $tasks, bool $machineAllowed): array + { + $merged = []; + foreach ($this->normalizeServiceList($services) as $service) { + $merged[$service] = true; + } + foreach ($tasks as $task) { + if (!is_array($task)) { + continue; + } + foreach ($this->normalizeServiceList($task['services'] ?? []) as $service) { + $merged[$service] = true; + } + } + if ($machineAllowed) { + $merged['MACHINE'] = true; + } + + $values = array_keys($merged); + sort($values); + return $values; + } + + /** + * @param array $scope + * @param array> $answers + */ + private function pathEditorPathKey(array $scope, array $answers): string + { + return 'path_' . substr(hash('sha256', $this->stableJson([ + 'scope' => $scope, + 'answers' => $answers, + ])), 0, 16); + } + + /** + * @param array $config + * @param array $scope + * @param array> $answers + * @param array $result + */ + private function upsertPathEditorCondition(int $departmentId, array &$config, string $pathKey, array $scope, array $answers, array $result, ?int $conditionId): int + { + if ($conditionId === null || $this->configRowIndex((array)($config['conditions'] ?? []), $conditionId) === null) { + $conditionId = $this->allocateConfigId($config, 'condition'); + $rows = &$this->configRows($config, 'condition'); + $rows[] = [ + 'id' => $conditionId, + 'department' => $departmentId, + 'lane' => 0, + 'product' => 0, + 'machine_type_id' => null, + 'condition_id' => null, + 'name' => 'Generated path condition', + 'description' => '', + 'expression' => $this->emptyV2Expression(), + ]; + unset($rows); + } + + $name = trim((string)($result['condition_name'] ?? '')); + if ($name === '') { + $name = 'Path: ' . $this->pathEditorAnswerSummary($answers); + } + $rows = &$this->configRows($config, 'condition'); + $index = $this->configRowIndex($rows, $conditionId); + if ($index === null) { + throw new \RuntimeException('Generated path condition could not be created.'); + } + $rows[$index] = $this->mergeConfigEntityData('condition', $rows[$index], [ + 'department' => $departmentId, + 'lane' => $scope['lane_id'] ?? 0, + 'product' => $scope['vehicle_type_id'] ?? 0, + 'machine_type_id' => $scope['machine_type_id'] ?? null, + 'name' => $name, + 'description' => 'Generated by Path Editor for ' . $this->pathEditorAnswerSummary($answers), + 'expression' => $this->pathEditorConditionExpression($answers), + ]); + $rows[$index]['generated_by'] = 'path_editor'; + $rows[$index]['path_key'] = $pathKey; + unset($rows); + + return $conditionId; + } + + /** + * @param array $config + * @param array $scope + * @param array $result + */ + private function upsertPathEditorTask(int $departmentId, array &$config, string $pathKey, array $scope, array $result, int $conditionId, ?int $taskId, int $orderPriority): int + { + if ($taskId === null || $this->configRowIndex((array)($config['tasks'] ?? []), $taskId) === null) { + $taskId = $this->allocateConfigId($config, 'task'); + $rows = &$this->configRows($config, 'task'); + $rows[] = [ + 'id' => $taskId, + 'department' => $departmentId, + 'lane' => 0, + 'product' => 0, + 'machine_type_id' => null, + 'condition_id' => null, + 'gate_type' => selfserve_task_gate_type::CONDITION->value, + 'gate_ref_id' => $conditionId, + 'task' => 'Start machine', + 'description' => '', + 'order_priority' => $orderPriority, + 'services' => [], + 'buttons' => [], + 'dynamic_images_vehicle_type' => null, + ]; + unset($rows); + } + + $rows = &$this->configRows($config, 'task'); + $index = $this->configRowIndex($rows, $taskId); + if ($index === null) { + throw new \RuntimeException('Generated path task could not be created.'); + } + $rows[$index] = $this->mergeConfigEntityData('task', $rows[$index], [ + 'department' => $departmentId, + 'lane' => $scope['lane_id'] ?? 0, + 'product' => $scope['vehicle_type_id'] ?? 0, + 'machine_type_id' => $scope['machine_type_id'] ?? null, + 'condition_id' => $conditionId, + 'gate_type' => selfserve_task_gate_type::CONDITION->value, + 'gate_ref_id' => $conditionId, + 'task' => $result['task'], + 'description' => $result['description'], + 'order_priority' => $orderPriority, + 'services' => $result['services'], + 'buttons' => $result['buttons'], + 'dynamic_images_vehicle_type' => $result['dynamic_images_vehicle_type'], + ]); + $rows[$index]['condition_id'] = $conditionId; + $rows[$index]['generated_by'] = 'path_editor'; + $rows[$index]['path_key'] = $pathKey; + unset($rows); + + return $taskId; + } + + /** + * @param array $existing + * @param array $data + * @return array + */ + private function pathEditorExistingTaskIds(array $existing, array $data): array + { + $ids = []; + foreach ([$existing['task_ids'] ?? null, $data['task_ids'] ?? null] as $taskIds) { + if (!is_array($taskIds)) { + continue; + } + foreach ($taskIds as $taskId) { + $normalizedTaskId = $this->nullableInt($taskId); + if ($normalizedTaskId !== null) { + $ids[] = $normalizedTaskId; + } + } + } + + foreach ([ + $existing['task_id'] ?? null, + $data['task_id'] ?? null, + $data['existing_task_id'] ?? null, + ] as $taskId) { + $normalizedTaskId = $this->nullableInt($taskId); + if ($normalizedTaskId !== null) { + $ids[] = $normalizedTaskId; + } + } + + return array_values(array_unique(array_filter($ids, static fn(int $taskId): bool => $taskId > 0))); + } + + /** + * @param array $config + * @param array $taskIds + */ + private function pathEditorTaskBaseOrderPriority(array $config, array $taskIds): int + { + $taskRows = (array)($config['tasks'] ?? []); + foreach ($taskIds as $taskId) { + $index = $this->configRowIndex($taskRows, $taskId); + if ($index !== null && is_array($taskRows[$index] ?? null)) { + return (int)($taskRows[$index]['order_priority'] ?? 0); + } + } + + return $this->nextOrderPriority($taskRows); + } + + /** + * @param array> $answers + * @return array + */ + private function pathEditorConditionExpression(array $answers): array + { + return [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => array_values(array_map(static fn(array $answer): array => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => (int)($answer['question_id'] ?? 0), + 'operator' => ((bool)($answer['value'] ?? $answer['answer'] ?? false)) ? 'IS_TRUE' : 'IS_FALSE', + ], $answers)), + ]; + } + + /** + * @param array> $answers + */ + private function pathEditorAnswerSummary(array $answers): string + { + $parts = []; + foreach ($answers as $answer) { + $parts[] = 'Q' . (int)($answer['question_id'] ?? 0) . '=' . (((bool)($answer['value'] ?? $answer['answer'] ?? false)) ? 'Yes' : 'No'); + } + return implode(', ', $parts) ?: 'answers'; + } + + /** + * @param array $scope + * @param array> $answers + */ + private function pathSignature(array $scope, array $answers): string + { + return hash('sha256', $this->stableJson([ + 'scope' => $this->pathScopeKey($scope), + 'answers' => array_values(array_map(static fn(array $answer): array => [ + 'question_id' => (int)($answer['question_id'] ?? 0), + 'answer' => (bool)($answer['answer'] ?? $answer['value'] ?? false), + ], $answers)), + ])); + } + + /** + * @param array $result + */ + private function pathEditorResultSignature(array $result, array $taskIds): string + { + $tasks = []; + if ((bool)($result['machine_allowed'] ?? false)) { + foreach (array_values((array)($result['tasks'] ?? [])) as $index => $task) { + if (!is_array($task)) { + continue; + } + $tasks[] = [ + 'id' => (int)($taskIds[$index] ?? 0), + 'label' => (string)($task['task'] ?? $task['label'] ?? ''), + 'services' => $this->normalizeServiceList($task['services'] ?? []), + 'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []), + 'dynamic_images_vehicle_type' => $this->nullableInt($task['dynamic_images_vehicle_type'] ?? null), + ]; + } + } + + return hash('sha256', $this->stableJson([ + 'allowed' => (bool)($result['machine_allowed'] ?? false), + 'services' => $this->normalizeServiceList($result['services'] ?? []), + 'tasks' => $tasks, + 'buttons' => $this->normalizeArrayPayload($result['buttons'] ?? []), + 'signals' => [], + ])); + } + + /** + * @param array $config + * @param array $data + */ + private function createConfigEntity(int $departmentId, array &$config, string $entity, array $data): void + { + $id = $this->allocateConfigId($config, $entity); + $row = match ($entity) { + 'question' => [ + 'id' => $id, + 'department' => $departmentId, + 'lane' => 0, + 'product' => 0, + 'condition_id' => null, + 'question' => 'New question', + 'description' => '', + 'order_priority' => $this->nextOrderPriority((array)($config['questions'] ?? [])), + ], + 'condition' => [ + 'id' => $id, + 'department' => $departmentId, + 'lane' => 0, + 'product' => 0, + 'machine_type_id' => null, + 'condition_id' => null, + 'name' => 'New condition', + 'description' => '', + 'expression' => $this->emptyV2Expression(), + ], + 'task' => [ + 'id' => $id, + 'department' => $departmentId, + 'lane' => 0, + 'product' => 0, + 'machine_type_id' => null, + 'condition_id' => null, + 'gate_type' => selfserve_task_gate_type::ALWAYS->value, + 'gate_ref_id' => null, + 'task' => 'New task', + 'description' => '', + 'order_priority' => $this->nextOrderPriority((array)($config['tasks'] ?? [])), + 'services' => [], + 'buttons' => [], + 'dynamic_images_vehicle_type' => null, + ], + 'action' => [ + 'id' => $id, + 'department' => $departmentId, + 'lane' => 0, + 'product' => 0, + 'machine_type_id' => null, + 'condition_id' => null, + 'name' => 'Open lane entrance port', + 'description' => '', + 'event' => selfserve_studio_actions::EVENT_WASH_START_COMMAND, + 'wash_mode' => selfserve_studio_actions::MODE_BOTH, + 'operation' => selfserve_studio_actions::OP_OPEN_LANE_ENTRANCE_PORT, + 'relay_state' => null, + 'enabled' => true, + 'order_priority' => $this->nextOrderPriority((array)($config['actions'] ?? [])), + 'options' => [ + 'delay_ms' => 0, + 'toggle_after_seconds' => 1, + 'retry_count' => 0, + 'failure_policy' => selfserve_studio_actions::FAILURE_CONTINUE, + 'record_event' => true, + ], + ], + default => throw new \RuntimeException('Unsupported studio entity: ' . $entity), + }; + + $row = $this->mergeConfigEntityData($entity, $row, $data); + $rows = &$this->configRows($config, $entity); + $rows[] = $row; + } + + /** + * @param array $config + * @param array $data + */ + private function updateConfigEntity(int $departmentId, array &$config, string $entity, int $id, array $data): void + { + unset($departmentId); + $rows = &$this->configRows($config, $entity); + $index = $this->configRowIndex($rows, $id); + if ($index === null) { + throw new \RuntimeException(ucfirst($entity) . ' is not available in the current draft.'); + } + + $rows[$index] = $this->mergeConfigEntityData($entity, $rows[$index], $data); + } + + /** + * @param array $config + */ + private function deleteConfigEntity(array &$config, string $entity, int $id): void + { + $rows = &$this->configRows($config, $entity); + $rows = array_values(array_filter($rows, static fn(array $row): bool => (int)($row['id'] ?? 0) !== $id)); + + if ($entity === 'question') { + $conditionRows = &$this->configRows($config, 'condition'); + foreach ($conditionRows as &$condition) { + if (is_array($condition) && is_array($condition['expression'] ?? null)) { + $condition['expression'] = $this->removeExpressionPredicate((array)$condition['expression'], 'question', $id); + } + } + unset($condition); + $taskRows = &$this->configRows($config, 'task'); + foreach ($taskRows as &$task) { + if (is_array($task) && strtoupper((string)($task['gate_type'] ?? '')) === selfserve_task_gate_type::QUESTION->value && (int)($task['gate_ref_id'] ?? 0) === $id) { + $task['gate_type'] = selfserve_task_gate_type::ALWAYS->value; + $task['gate_ref_id'] = null; + $task['condition_id'] = null; + } + } + unset($task); + } + + if ($entity === 'condition') { + $conditionRows = &$this->configRows($config, 'condition'); + foreach ($conditionRows as &$condition) { + if (!is_array($condition)) { + continue; + } + if ((int)($condition['condition_id'] ?? 0) === $id) { + $condition['condition_id'] = null; + } + if (is_array($condition['expression'] ?? null)) { + $condition['expression'] = $this->removeExpressionPredicate((array)$condition['expression'], 'condition', $id); + } + } + unset($condition); + $questionRows = &$this->configRows($config, 'question'); + foreach ($questionRows as &$question) { + if (is_array($question) && (int)($question['condition_id'] ?? 0) === $id) { + $question['condition_id'] = null; + } + } + unset($question); + $taskRows = &$this->configRows($config, 'task'); + foreach ($taskRows as &$task) { + if (is_array($task) && strtoupper((string)($task['gate_type'] ?? '')) === selfserve_task_gate_type::CONDITION->value && (int)($task['gate_ref_id'] ?? 0) === $id) { + $task['gate_type'] = selfserve_task_gate_type::ALWAYS->value; + $task['gate_ref_id'] = null; + $task['condition_id'] = null; + } + } + unset($task); + $actionRows = &$this->configRows($config, 'action'); + foreach ($actionRows as &$action) { + if (is_array($action) && (int)($action['condition_id'] ?? 0) === $id) { + $action['condition_id'] = null; + } + } + unset($action); + } + } + + /** + * @param array $config + * @param array> $items + */ + private function applyConfigReorder(array &$config, string $entity, array $items): void + { + if (!in_array($entity, ['question', 'task', 'action'], true)) { + throw new \RuntimeException('Only questions, tasks, and actions can be reordered.'); + } + + $priorities = []; + foreach ($items as $index => $item) { + if (is_array($item)) { + $priorities[(int)($item['id'] ?? 0)] = (int)($item['order_priority'] ?? $index); + } + } + + $rows = &$this->configRows($config, $entity); + foreach ($rows as &$row) { + $id = (int)($row['id'] ?? 0); + if (isset($priorities[$id])) { + $row['order_priority'] = $priorities[$id]; + } + } + unset($row); + } + + /** + * @param array $config + */ + private function applyConfigConnection(int $departmentId, array &$config, string $source, string $target, bool $disconnect): void + { + [$sourceType, $sourceIdRaw] = $this->parseNodeId($source); + [$targetType, $targetIdRaw] = $this->parseNodeId($target); + if ($sourceType === '' || $targetType === '' || $sourceIdRaw === '') { + throw new \RuntimeException('Invalid connection endpoints.'); + } + + if ($sourceType === 'task' && $targetType === 'binding') { + $service = $this->serviceForBindingNode($departmentId, $target); + if ($service === '') { + throw new \RuntimeException('Relay binding has no service role to connect to the task.'); + } + $this->updateConfigTaskServiceConnection($config, (int)$sourceIdRaw, $service, $disconnect); + return; + } + if ($sourceType === 'binding' && $targetType === 'task') { + $service = $this->serviceForBindingNode($departmentId, $source); + if ($service === '') { + throw new \RuntimeException('Relay binding has no service role to connect to the task.'); + } + $this->updateConfigTaskServiceConnection($config, (int)$targetIdRaw, $service, $disconnect); + return; + } + + $sourceId = (int)$sourceIdRaw; + $targetId = (int)$targetIdRaw; + if ($sourceId <= 0 || $targetId <= 0) { + throw new \RuntimeException('Invalid connection endpoints.'); + } + + if (in_array($sourceType, ['question', 'condition'], true) && $targetType === 'condition') { + if ($sourceType === 'condition' && $sourceId === $targetId && !$disconnect) { + throw new \RuntimeException('Condition expressions cannot reference themselves.'); + } + $this->updateConditionExpressionConnection($config, $targetId, $sourceType, $sourceId, $disconnect); + return; + } + + if ($sourceType === 'condition' && $targetType === 'question') { + $rows = &$this->configRows($config, 'question'); + $index = $this->configRowIndex($rows, $targetId); + if ($index !== null && (!$disconnect || (int)($rows[$index]['condition_id'] ?? 0) === $sourceId)) { + $rows[$index]['condition_id'] = $disconnect ? null : $sourceId; + } + return; + } + + if (in_array($sourceType, ['condition', 'question'], true) && $targetType === 'task') { + $rows = &$this->configRows($config, 'task'); + $index = $this->configRowIndex($rows, $targetId); + if ($index === null) { + return; + } + $currentType = strtoupper((string)($rows[$index]['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value)); + $currentRef = (int)($rows[$index]['gate_ref_id'] ?? 0); + if ($disconnect && ($currentType !== strtoupper($sourceType) || $currentRef !== $sourceId)) { + return; + } + $rows[$index]['gate_type'] = $disconnect ? selfserve_task_gate_type::ALWAYS->value : strtoupper($sourceType); + $rows[$index]['gate_ref_id'] = $disconnect ? null : $sourceId; + $rows[$index]['condition_id'] = (!$disconnect && $sourceType === 'question') ? $sourceId : null; + return; + } + + if ($sourceType === 'condition' && $targetType === 'action') { + $rows = &$this->configRows($config, 'action'); + $index = $this->configRowIndex($rows, $targetId); + if ($index !== null && (!$disconnect || (int)($rows[$index]['condition_id'] ?? 0) === $sourceId)) { + $rows[$index]['condition_id'] = $disconnect ? null : $sourceId; + } + return; + } + } + + /** + * @param array $config + */ + private function updateConditionExpressionConnection(array &$config, int $conditionId, string $subjectType, int $subjectId, bool $disconnect): void + { + $rows = &$this->configRows($config, 'condition'); + $index = $this->configRowIndex($rows, $conditionId); + if ($index === null) { + throw new \RuntimeException('Condition is not available in the current draft.'); + } + + $expression = $this->normalizeExpressionNode($rows[$index]['expression'] ?? $this->emptyV2Expression()); + if (($expression['type'] ?? 'group') === 'predicate') { + $expression = [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [$expression], + ]; + } elseif (($expression['type'] ?? 'group') !== 'group') { + $expression = [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [$expression], + ]; + } + if ($disconnect) { + $rows[$index]['expression'] = $this->removeExpressionPredicate($expression, $subjectType, $subjectId); + return; + } + + if (!$this->expressionHasPredicate($expression, $subjectType, $subjectId)) { + $expression['children'][] = [ + 'type' => 'predicate', + 'subject_type' => $subjectType, + 'subject_id' => $subjectId, + 'operator' => 'IS_TRUE', + ]; + } + $rows[$index]['expression'] = $expression; + } + + /** + * @param array $config + */ + private function updateConfigTaskServiceConnection(array &$config, int $taskId, string $service, bool $disconnect): void + { + if ($taskId <= 0 || $service === '') { + return; + } + + $rows = &$this->configRows($config, 'task'); + $index = $this->configRowIndex($rows, $taskId); + if ($index === null) { + throw new \RuntimeException('Task is not available in the current draft.'); + } + + $services = array_fill_keys($this->normalizeServiceList($rows[$index]['services'] ?? []), true); + if ($disconnect) { + unset($services[$service]); + } else { + $services[$service] = true; + } + $rows[$index]['services'] = array_keys($services); + } + + /** + * @param array $config + * @return array> + */ + private function &configRows(array &$config, string $entity): array + { + $key = match ($entity) { + 'question' => 'questions', + 'condition' => 'conditions', + 'task' => 'tasks', + 'action' => 'actions', + default => throw new \RuntimeException('Unsupported studio entity: ' . $entity), + }; + if (!isset($config[$key]) || !is_array($config[$key])) { + $config[$key] = []; + } + return $config[$key]; + } + + /** + * @param array> $rows + */ + private function configRowIndex(array $rows, int $id): ?int + { + foreach ($rows as $index => $row) { + if ((int)($row['id'] ?? 0) === $id) { + return (int)$index; + } + } + return null; + } + + /** + * @param array $config + */ + private function allocateConfigId(array &$config, string $entity): int + { + $name = match ($entity) { + 'question', 'condition', 'task', 'action' => $entity, + default => throw new \RuntimeException('Unsupported studio entity: ' . $entity), + }; + if (!isset($config['v2_meta']) || !is_array($config['v2_meta'])) { + $config['v2_meta'] = []; + } + if (!isset($config['v2_meta']['next_ids']) || !is_array($config['v2_meta']['next_ids'])) { + $config['v2_meta']['next_ids'] = []; + } + + $rows = &$this->configRows($config, $entity); + $maxId = 0; + foreach ($rows as $row) { + $maxId = max($maxId, (int)($row['id'] ?? 0)); + } + + $nextId = max((int)($config['v2_meta']['next_ids'][$name] ?? 0), $maxId + 1); + $config['v2_meta']['next_ids'][$name] = $nextId + 1; + return $nextId; + } + + /** + * @param array> $rows + */ + private function nextOrderPriority(array $rows): int + { + $max = 0; + foreach ($rows as $row) { + $max = max($max, (int)($row['order_priority'] ?? 0)); + } + return $max + 10; + } + + /** + * @param array $row + * @param array $data + * @return array + */ + private function mergeConfigEntityData(string $entity, array $row, array $data): array + { + if (array_key_exists('label', $data)) { + if ($entity === 'question' && !array_key_exists('question', $data)) { + $data['question'] = $data['label']; + } elseif ($entity === 'condition' && !array_key_exists('name', $data)) { + $data['name'] = $data['label']; + } elseif ($entity === 'task' && !array_key_exists('task', $data)) { + $data['task'] = $data['label']; + } elseif ($entity === 'action' && !array_key_exists('name', $data)) { + $data['name'] = $data['label']; + } + } + + $fields = match ($entity) { + 'question' => ['department', 'lane', 'product', 'condition_id', 'question', 'description', 'order_priority'], + 'condition' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'expression'], + 'task' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type'], + 'action' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'event', 'wash_mode', 'operation', 'relay_state', 'enabled', 'order_priority', 'options'], + default => throw new \RuntimeException('Unsupported studio entity: ' . $entity), + }; + + foreach ($fields as $field) { + if (!array_key_exists($field, $data)) { + continue; + } + $row[$field] = $this->normalizeConfigField($field, $data[$field]); + } + + if ($entity === 'condition' && !is_array($row['expression'] ?? null)) { + $row['expression'] = $this->emptyV2Expression(); + } + if ($entity === 'task') { + $row['gate_type'] = $this->normalizeGateType((string)($row['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value)); + if ($row['gate_type'] === selfserve_task_gate_type::ALWAYS->value) { + $row['gate_ref_id'] = null; + $row['condition_id'] = null; + } + } + if ($entity === 'action') { + $row = selfserve_studio_actions::normalize($row); + } + + return $row; + } + + private function normalizeConfigField(string $field, mixed $value): mixed + { + if ($field === 'expression') { + return $this->normalizeExpressionNode($value); + } + if (in_array($field, ['condition_id', 'machine_type_id', 'gate_ref_id', 'dynamic_images_vehicle_type', 'toggle_after_seconds'], true)) { + return $this->nullableInt($value); + } + if (in_array($field, ['department', 'lane', 'product', 'order_priority', 'delay_ms', 'retry_count'], true)) { + return (int)$value; + } + if (in_array($field, ['enabled', 'relay_state', 'record_event'], true)) { + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + } + if ($field === 'services') { + return $this->normalizeServiceList($value); + } + if ($field === 'buttons') { + return $this->normalizeArrayPayload($value); + } + if ($field === 'options') { + return is_array($value) ? (array)$value : []; + } + if ($field === 'gate_type') { + return $this->normalizeGateType((string)$value); + } + return is_array($value) ? $value : (string)$value; + } + + /** + * @param array $operation + */ + private function applyOperation(int $departmentId, array $operation, array $permissions = []): void + { + $action = strtolower((string)($operation['action'] ?? '')); + $entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? '')); + $data = is_array($operation['data'] ?? null) ? (array)$operation['data'] : []; + $id = (int)($operation['id'] ?? $data['id'] ?? 0); + + if ($action === 'connect') { + $this->applyConnection($departmentId, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), false); + return; + } + if ($action === 'disconnect') { + $this->applyConnection($departmentId, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), true); + return; + } + if ($action === 'reorder') { + $this->applyReorder($entity, (array)($operation['items'] ?? [])); + return; + } + if ($entity === '') { + throw new \RuntimeException('Studio graph operation is missing entity.'); + } + if ($entity === 'lane') { + $this->applyLaneOperation($departmentId, $action, $id, $data, $permissions); + return; + } + + if ($action === 'create') { + $this->createEntity($departmentId, $entity, $data); + return; + } + if ($id <= 0) { + throw new \RuntimeException('Studio graph operation is missing id.'); + } + if ($action === 'update') { + $this->updateEntity($departmentId, $entity, $id, $data); + return; + } + if ($action === 'delete') { + $this->softDeleteEntity($departmentId, $entity, $id); + return; + } + + throw new \RuntimeException('Unsupported studio graph operation: ' . $action); + } + + /** + * @param array $data + */ + private function applyLaneOperation(int $departmentId, string $action, int $id, array $data, array $permissions = []): void + { + if (!$this->tableExists('department_lanes')) { + throw new \RuntimeException('Department lanes are not available.'); + } + + $this->assertLaneOperationAuthorized($action, $data, $permissions); + + if ($action === 'create') { + $this->createLane($departmentId, $data); + return; + } + + if ($id <= 0) { + throw new \RuntimeException('Studio lane operation is missing id.'); + } + + if (!$this->laneBelongsToDepartment($id, $departmentId)) { + throw new \RuntimeException('Lane is not available in the selected department.'); + } + + if ($action === 'update') { + $this->updateLane($departmentId, $id, $data); + return; + } + + if ($action === 'delete') { + $this->softDeleteLane($departmentId, $id); + return; + } + + throw new \RuntimeException('Unsupported studio lane operation: ' . $action); + } + + /** + * @param array $data + * @param array $permissions + */ + private function assertLaneOperationAuthorized(string $action, array $data, array $permissions): void + { + if ($action === 'create' && !($permissions['can_add_department_lane'] ?? false)) { + throw new \RuntimeException('Missing permission: add_department_lane.'); + } + if (in_array($action, ['update', 'delete'], true) && !($permissions['can_edit_department_lane'] ?? false)) { + throw new \RuntimeException('Missing permission: edit_department_lane.'); + } + + if (!in_array($action, ['create', 'update'], true)) { + return; + } + + $relayFields = [ + 'relay_in_id', + 'relay_out_id', + 'relay_machine_id', + 'relay_machine_program_picker_id', + 'relay_machine_cleaner_id', + ]; + foreach ($relayFields as $field) { + if (array_key_exists($field, $data) && !($permissions['modules_shelly_config'] ?? false)) { + throw new \RuntimeException('Missing permission: modules_shelly_config.'); + } + } + } + + /** + * @param array $data + */ + private function createLane(int $departmentId, array $data): void + { + $fields = [ + 'department' => $departmentId, + 'name' => $this->normalizeLaneName((string)($data['name'] ?? $data['label'] ?? 'New lane')), + ]; + + foreach ($this->laneOptionalFields() as $field) { + if (array_key_exists($field, $data)) { + $fields[$field] = $this->normalizeLaneField($field, $data[$field]); + } + } + + $availableColumns = $this->tableColumns('department_lanes'); + $fields = array_filter( + $fields, + static fn(mixed $value, string $field): bool => in_array($field, $availableColumns, true), + ARRAY_FILTER_USE_BOTH + ); + + $columns = array_keys($fields); + $placeholders = array_map(static fn(string $field): string => ':' . $field, $columns); + $params = []; + foreach ($fields as $field => $value) { + $params[':' . $field] = $value; + } + + db::getPDO()->prepare( + 'INSERT INTO department_lanes (`' . implode('`, `', $columns) . '`) VALUES (' . implode(', ', $placeholders) . ')' + )->execute($params); + } + + /** + * @param array $data + */ + private function updateLane(int $departmentId, int $id, array $data): void + { + unset($departmentId); + $availableColumns = $this->tableColumns('department_lanes'); + $updates = []; + $params = [':id' => $id]; + $wasSelfServeEnabled = null; + + if (array_key_exists('selfserve_enabled', $data) && in_array('selfserve_enabled', $availableColumns, true)) { + $statement = db::getPDO()->prepare( + 'SELECT selfserve_enabled FROM department_lanes WHERE id = :id AND deleted_at IS NULL' + ); + $statement->execute([':id' => $id]); + $wasSelfServeEnabled = ((int)($statement->fetch(\PDO::FETCH_ASSOC)['selfserve_enabled'] ?? 1)) === 1; + } + + $fields = ['name', ...$this->laneOptionalFields()]; + foreach ($fields as $field) { + if (!array_key_exists($field, $data) || !in_array($field, $availableColumns, true)) { + continue; + } + $updates[] = '`' . $field . '` = :' . $field; + $params[':' . $field] = $field === 'name' + ? $this->normalizeLaneName((string)$data[$field]) + : $this->normalizeLaneField($field, $data[$field]); + } + + if ($updates === []) { + return; + } + + db::getPDO()->prepare( + 'UPDATE department_lanes SET ' . implode(', ', $updates) . ' WHERE id = :id AND deleted_at IS NULL' + )->execute($params); + + if ( + $wasSelfServeEnabled === true + && array_key_exists(':selfserve_enabled', $params) + && (int)$params[':selfserve_enabled'] === 0 + ) { + \objects\department_lanes_o::disableSelfServeRelaysBestEffort($id); + } + } + + private function softDeleteLane(int $departmentId, int $id): void + { + unset($departmentId); + db::getPDO()->prepare( + 'UPDATE department_lanes SET deleted_at = NOW() WHERE id = :id AND deleted_at IS NULL' + )->execute([':id' => $id]); + } + + /** + * @return array + */ + private function laneOptionalFields(): array + { + return [ + 'relay_in_id', + 'relay_out_id', + 'relay_machine_id', + 'relay_machine_program_picker_id', + 'relay_machine_cleaner_id', + 'dynamic_image_id', + 'machine_type_id', + 'selfserve_enabled', + ]; + } + + private function normalizeLaneField(string $field, mixed $value): mixed + { + if ($field === 'selfserve_enabled') { + return \objects\department_lanes_o::normalizeSelfServeEnabledValue($value) ? 1 : 0; + } + + if (in_array($field, ['dynamic_image_id', 'machine_type_id'], true)) { + return $this->nullableInt($value); + } + + $normalized = trim((string)($value ?? '')); + if ($normalized === '' || $normalized === '0' || strtolower($normalized) === 'null') { + return null; + } + + return $normalized; + } + + private function normalizeLaneName(string $name): string + { + $normalized = trim($name); + if ($normalized === '') { + throw new \RuntimeException('Lane name is required.'); + } + + return $normalized; + } + + /** + * @param array $data + */ + private function createEntity(int $departmentId, string $entity, array $data): void + { + $pdo = db::getPDO(); + if ($entity === 'question') { + $pdo->prepare( + "INSERT INTO department_selfserve_questions (department, lane, product, condition_id, question, description, order_priority) + VALUES (:department, :lane, :product, :condition_id, :question, :description, :order_priority)" + )->execute([ + ':department' => $departmentId, + ':lane' => (int)($data['lane'] ?? 0), + ':product' => (int)($data['product'] ?? 0), + ':condition_id' => $this->nullableInt($data['condition_id'] ?? null), + ':question' => (string)($data['question'] ?? $data['label'] ?? 'New question'), + ':description' => (string)($data['description'] ?? ''), + ':order_priority' => (int)($data['order_priority'] ?? 0), + ]); + return; + } + if ($entity === 'condition') { + $pdo->prepare( + "INSERT INTO department_selfserve_conditions (department, lane, product, machine_type_id, condition_id, name, description) + VALUES (:department, :lane, :product, :machine_type_id, :condition_id, :name, :description)" + )->execute([ + ':department' => $departmentId, + ':lane' => (int)($data['lane'] ?? 0), + ':product' => (int)($data['product'] ?? 0), + ':machine_type_id' => $this->nullableInt($data['machine_type_id'] ?? null), + ':condition_id' => $this->nullableInt($data['condition_id'] ?? null), + ':name' => (string)($data['name'] ?? $data['label'] ?? 'New condition'), + ':description' => (string)($data['description'] ?? ''), + ]); + return; + } + if ($entity === 'rule') { + $conditionId = (int)($data['condition_id'] ?? 0); + if (!$this->conditionBelongsToDepartment($conditionId, $departmentId)) { + throw new \RuntimeException('Rule condition_id is not available in the selected department.'); + } + $pdo->prepare( + "INSERT INTO department_selfserve_condition_rules (condition_id, type, object_type, object_id, name, description) + VALUES (:condition_id, :type, :object_type, :object_id, :name, :description)" + )->execute([ + ':condition_id' => $conditionId, + ':type' => (string)($data['type'] ?? 'IS_TRUE'), + ':object_type' => (string)($data['object_type'] ?? 'question'), + ':object_id' => (int)($data['object_id'] ?? 0), + ':name' => (string)($data['name'] ?? $data['label'] ?? 'New rule'), + ':description' => (string)($data['description'] ?? ''), + ]); + return; + } + if ($entity === 'task') { + $gateType = $this->normalizeGateType((string)($data['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value)); + $gateRefId = $gateType === selfserve_task_gate_type::ALWAYS->value ? null : $this->nullableInt($data['gate_ref_id'] ?? null); + $pdo->prepare( + "INSERT INTO department_selfserve_tasks (department, lane, product, machine_type_id, condition_id, gate_type, gate_ref_id, task, description, order_priority, services, buttons, dynamic_images_vehicle_type) + VALUES (:department, :lane, :product, :machine_type_id, :condition_id, :gate_type, :gate_ref_id, :task, :description, :order_priority, :services, :buttons, :dynamic_images_vehicle_type)" + )->execute([ + ':department' => $departmentId, + ':lane' => (int)($data['lane'] ?? 0), + ':product' => (int)($data['product'] ?? 0), + ':machine_type_id' => $this->nullableInt($data['machine_type_id'] ?? null), + ':condition_id' => $gateType === selfserve_task_gate_type::QUESTION->value ? $gateRefId : null, + ':gate_type' => $gateType, + ':gate_ref_id' => $gateRefId, + ':task' => (string)($data['task'] ?? $data['label'] ?? 'New task'), + ':description' => (string)($data['description'] ?? ''), + ':order_priority' => (int)($data['order_priority'] ?? 0), + ':services' => $this->jsonArray($data['services'] ?? []), + ':buttons' => $this->jsonArray($data['buttons'] ?? []), + ':dynamic_images_vehicle_type' => $this->nullableInt($data['dynamic_images_vehicle_type'] ?? null), + ]); + return; + } + + throw new \RuntimeException('Unsupported studio entity: ' . $entity); + } + + /** + * @param array $data + */ + private function updateEntity(int $departmentId, string $entity, int $id, array $data): void + { + $map = [ + 'question' => [ + 'table' => 'department_selfserve_questions', + 'fields' => ['lane', 'product', 'condition_id', 'question', 'description', 'order_priority'], + 'department' => 'department', + ], + 'condition' => [ + 'table' => 'department_selfserve_conditions', + 'fields' => ['lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description'], + 'department' => 'department', + ], + 'rule' => [ + 'table' => 'department_selfserve_condition_rules', + 'fields' => ['condition_id', 'type', 'object_type', 'object_id', 'name', 'description'], + 'department' => null, + ], + 'task' => [ + 'table' => 'department_selfserve_tasks', + 'fields' => ['lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type'], + 'department' => 'department', + ], + ]; + if (!isset($map[$entity])) { + throw new \RuntimeException('Unsupported studio entity: ' . $entity); + } + if ($entity === 'rule' && array_key_exists('condition_id', $data) && !$this->conditionBelongsToDepartment((int)$data['condition_id'], $departmentId)) { + throw new \RuntimeException('Rule condition_id is not available in the selected department.'); + } + + $updates = []; + $params = [ + ':id' => $id, + ]; + foreach ($map[$entity]['fields'] as $field) { + if (!array_key_exists($field, $data)) { + continue; + } + $updates[] = "`$field` = :$field"; + $value = $data[$field]; + if (in_array($field, ['condition_id', 'machine_type_id', 'gate_ref_id', 'dynamic_images_vehicle_type'], true)) { + $value = $this->nullableInt($value); + } elseif (in_array($field, ['services', 'buttons'], true)) { + $value = $this->jsonArray($value); + } elseif ($field === 'gate_type') { + $value = $this->normalizeGateType((string)$value); + } + $params[':' . $field] = $value; + } + + if (isset($data['label'])) { + if ($entity === 'question' && !isset($data['question'])) { + $updates[] = '`question` = :label'; + $params[':label'] = (string)$data['label']; + } elseif ($entity === 'condition' && !isset($data['name'])) { + $updates[] = '`name` = :label'; + $params[':label'] = (string)$data['label']; + } elseif ($entity === 'task' && !isset($data['task'])) { + $updates[] = '`task` = :label'; + $params[':label'] = (string)$data['label']; + } + } + + if ($updates === []) { + return; + } + + $where = 'id = :id'; + if ($entity === 'rule') { + $where .= " AND condition_id IN ( + SELECT id + FROM department_selfserve_conditions + WHERE department IN (0, :department) + AND deleted_at IS NULL + )"; + $params[':department'] = $departmentId; + } elseif ($map[$entity]['department'] !== null) { + $where .= ' AND `' . $map[$entity]['department'] . '` IN (0, :department)'; + $params[':department'] = $departmentId; + } + db::getPDO()->prepare( + 'UPDATE `' . $map[$entity]['table'] . '` SET ' . implode(', ', $updates) . ' WHERE ' . $where + )->execute($params); + } + + private function softDeleteEntity(int $departmentId, string $entity, int $id): void + { + $map = [ + 'question' => ['table' => 'department_selfserve_questions', 'department' => 'department'], + 'condition' => ['table' => 'department_selfserve_conditions', 'department' => 'department'], + 'rule' => ['table' => 'department_selfserve_condition_rules', 'department' => null], + 'task' => ['table' => 'department_selfserve_tasks', 'department' => 'department'], + ]; + if (!isset($map[$entity])) { + throw new \RuntimeException('Unsupported studio entity: ' . $entity); + } + + $params = [ + ':id' => $id, + ]; + $where = 'id = :id'; + if ($entity === 'rule') { + $where .= " AND condition_id IN ( + SELECT id + FROM department_selfserve_conditions + WHERE department IN (0, :department) + AND deleted_at IS NULL + )"; + $params[':department'] = $departmentId; + } elseif ($map[$entity]['department'] !== null) { + $where .= ' AND `' . $map[$entity]['department'] . '` IN (0, :department)'; + $params[':department'] = $departmentId; + } + db::getPDO()->prepare( + 'UPDATE `' . $map[$entity]['table'] . '` SET deleted_at = NOW() WHERE ' . $where + )->execute($params); + } + + /** + * @param array> $items + */ + private function applyReorder(string $entity, array $items): void + { + $table = match ($entity) { + 'question' => 'department_selfserve_questions', + 'task' => 'department_selfserve_tasks', + default => null, + }; + if ($table === null) { + throw new \RuntimeException('Only questions and tasks can be reordered.'); + } + + $statement = db::getPDO()->prepare('UPDATE `' . $table . '` SET order_priority = :order_priority WHERE id = :id'); + foreach ($items as $index => $item) { + if (!is_array($item)) { + continue; + } + $statement->execute([ + ':id' => (int)($item['id'] ?? 0), + ':order_priority' => (int)($item['order_priority'] ?? $index), + ]); + } + } + + private function applyConnection(int $departmentId, string $source, string $target, bool $disconnect): void + { + [$sourceType, $sourceId] = $this->parseNodeId($source); + [$targetType, $targetId] = $this->parseNodeId($target); + if ($sourceType === '' || $targetType === '' || $sourceId === '') { + throw new \RuntimeException('Invalid connection endpoints.'); + } + + if ($sourceType === 'task' && $targetType === 'binding') { + $service = $this->serviceForBindingNode($departmentId, $target); + if ($service === '') { + throw new \RuntimeException('Relay binding has no service role to connect to the task.'); + } + $this->updateTaskServiceConnection($departmentId, (int)$sourceId, $service, $disconnect); + return; + } + if ($sourceType === 'binding' && $targetType === 'task') { + $service = $this->serviceForBindingNode($departmentId, $source); + if ($service === '') { + throw new \RuntimeException('Relay binding has no service role to connect to the task.'); + } + $this->updateTaskServiceConnection($departmentId, (int)$targetId, $service, $disconnect); + return; + } + + if ($sourceType === 'condition' && $targetType === 'question') { + db::getPDO()->prepare('UPDATE department_selfserve_questions SET condition_id = :condition_id WHERE id = :id')->execute([ + ':condition_id' => $disconnect ? null : (int)$sourceId, + ':id' => (int)$targetId, + ]); + return; + } + if ($sourceType === 'condition' && $targetType === 'condition') { + db::getPDO()->prepare('UPDATE department_selfserve_conditions SET condition_id = :condition_id WHERE id = :id')->execute([ + ':condition_id' => $disconnect ? null : (int)$sourceId, + ':id' => (int)$targetId, + ]); + return; + } + if (in_array($sourceType, ['condition', 'question'], true) && $targetType === 'task') { + db::getPDO()->prepare('UPDATE department_selfserve_tasks SET gate_type = :gate_type, gate_ref_id = :gate_ref_id, condition_id = :legacy_question_id WHERE id = :id')->execute([ + ':gate_type' => $disconnect ? selfserve_task_gate_type::ALWAYS->value : strtoupper($sourceType), + ':gate_ref_id' => $disconnect ? null : (int)$sourceId, + ':legacy_question_id' => (!$disconnect && $sourceType === 'question') ? (int)$sourceId : null, + ':id' => (int)$targetId, + ]); + return; + } + if ($sourceType === 'condition' && $targetType === 'rule') { + db::getPDO()->prepare('UPDATE department_selfserve_condition_rules SET condition_id = :condition_id WHERE id = :id')->execute([ + ':condition_id' => $disconnect ? 0 : (int)$sourceId, + ':id' => (int)$targetId, + ]); + return; + } + if (in_array($sourceType, ['question', 'condition', 'task'], true) && $targetType === 'rule') { + db::getPDO()->prepare('UPDATE department_selfserve_condition_rules SET object_type = :object_type, object_id = :object_id WHERE id = :id')->execute([ + ':object_type' => $disconnect ? '' : $sourceType, + ':object_id' => $disconnect ? 0 : (int)$sourceId, + ':id' => (int)$targetId, + ]); + return; + } + } + + /** + * @param array $row + * @param array $lookups + * @return array + */ + private function scopeForRow(array $row, array $lookups): array + { + return [ + 'department' => $this->labelFor('departments', $row['department'] ?? null, $lookups), + 'lane' => $this->labelFor('lanes', $row['lane'] ?? null, $lookups), + 'product' => $this->labelFor('products', $row['product'] ?? null, $lookups), + 'machine_type' => $this->labelFor('machine_types', $row['machine_type_id'] ?? null, $lookups), + ]; + } + + /** + * @param array $row + */ + private function scopeLabel(array $row, array $lookups): string + { + $parts = []; + foreach (['lane' => 'lanes', 'product' => 'products', 'machine_type_id' => 'machine_types'] as $field => $lookupType) { + $value = $this->nullableInt($row[$field] ?? null); + if ($value !== null) { + $parts[] = $this->labelFor($lookupType, $value, $lookups); + } + } + + return $parts === [] ? 'Shared scope' : implode(' / ', $parts); + } + + /** + * @param array $row + */ + private function ruleSubtitle(array $row, array $lookups): string + { + $objectType = strtolower((string)($row['object_type'] ?? 'object')); + $objectId = (int)($row['object_id'] ?? 0); + $lookupType = $objectType . 's'; + $label = $objectId > 0 ? $this->labelFor($lookupType, $objectId, $lookups) : 'Unbound object'; + return strtoupper((string)($row['type'] ?? 'RULE')) . ' ' . $label; + } + + /** + * @param array $expression + */ + private function expressionSummary(array $expression): string + { + $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group')); + if ($type === 'predicate') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + $operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? 'IS_TRUE')); + $subjectLabel = match ($subjectType) { + 'question' => 'Question ' . $subjectId, + 'condition' => 'Condition ' . $subjectId, + default => 'Unknown subject', + }; + return $subjectLabel . ' ' . strtolower(str_replace('_', ' ', $operator)); + } + if (in_array($type, ['branch', 'if', 'if_else'], true)) { + $branches = is_array($expression['branches'] ?? null) ? array_values((array)$expression['branches']) : []; + if ($branches === []) { + return 'No if/else clauses'; + } + + $parts = []; + foreach (array_slice($branches, 0, 3) as $index => $branch) { + if (!is_array($branch)) { + continue; + } + $kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if'))); + $isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true); + $label = $isElse ? 'Else' : ($index === 0 ? 'If' : 'Else if'); + $when = !$isElse && is_array($branch['when'] ?? null) ? $this->expressionSummary((array)$branch['when']) : ''; + $then = is_array($branch['then'] ?? null) ? $this->expressionSummary((array)$branch['then']) : 'No result'; + $parts[] = trim($label . ($when === '' ? '' : ' ' . $when) . ' then ' . $then); + } + if (count($branches) > 3) { + $parts[] = '+' . (count($branches) - 3) . ' more'; + } + + return implode('; ', $parts); + } + if ($type === 'case') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + $subjectLabel = match ($subjectType) { + 'question' => 'Question ' . $subjectId, + 'condition' => 'Condition ' . $subjectId, + default => 'Unknown subject', + }; + $cases = is_array($expression['cases'] ?? null) ? array_values((array)$expression['cases']) : []; + if ($cases === []) { + return 'Case ' . $subjectLabel . ': no clauses'; + } + + $parts = []; + foreach (array_slice($cases, 0, 3) as $case) { + if (!is_array($case)) { + continue; + } + $value = $this->caseValueLabel($case['value'] ?? null); + $then = is_array($case['then'] ?? null) ? $this->expressionSummary((array)$case['then']) : 'No result'; + $parts[] = $value . ' then ' . $then; + } + if (count($cases) > 3) { + $parts[] = '+' . (count($cases) - 3) . ' more'; + } + + return 'Case ' . $subjectLabel . ': ' . implode('; ', $parts); + } + + $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL')); + if (!in_array($operator, ['ALL', 'ANY'], true)) { + $operator = 'ALL'; + } + $children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : []; + if ($children === []) { + return 'No predicates'; + } + + $parts = []; + foreach (array_slice($children, 0, 3) as $child) { + if (is_array($child)) { + $parts[] = $this->expressionSummary((array)$child); + } + } + if (count($children) > 3) { + $parts[] = '+' . (count($children) - 3) . ' more'; + } + + $prefix = $operator === 'ANY' ? 'Any of' : 'All of'; + return $prefix . ': ' . implode('; ', $parts); + } + + /** + * @param array> $edges + * @param array $expression + */ + private function appendExpressionEdges(array &$edges, string $targetNodeId, int $ownerConditionId, array $expression, string $path = '0'): void + { + $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group')); + if ($type === 'predicate') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + if (!in_array($subjectType, ['question', 'condition'], true) || $subjectId <= 0) { + return; + } + $edge = $this->edge( + 'expression:' . $ownerConditionId . ':' . $subjectType . ':' . $subjectId . ':' . substr(md5($path), 0, 8), + $subjectType . ':' . $subjectId, + $targetNodeId, + 'condition_expression', + strtoupper((string)($expression['operator'] ?? 'IS_TRUE')) + ); + $edge['data']['subject_type'] = $subjectType; + $edge['data']['subject_id'] = $subjectId; + $edge['data']['condition_id'] = $ownerConditionId; + $edges[] = $edge; + return; + } + if (in_array($type, ['branch', 'if', 'if_else'], true)) { + foreach ((array)($expression['branches'] ?? []) as $index => $branch) { + if (!is_array($branch)) { + continue; + } + if (is_array($branch['when'] ?? null)) { + $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$branch['when'], $path . '.b' . $index . '.when'); + } + if (is_array($branch['then'] ?? null)) { + $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$branch['then'], $path . '.b' . $index . '.then'); + } + } + if (is_array($expression['default'] ?? null)) { + $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$expression['default'], $path . '.default'); + } + return; + } + if ($type === 'case') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + if (in_array($subjectType, ['question', 'condition'], true) && $subjectId > 0) { + $edge = $this->edge( + 'expression:' . $ownerConditionId . ':' . $subjectType . ':' . $subjectId . ':' . substr(md5($path . '.case'), 0, 8), + $subjectType . ':' . $subjectId, + $targetNodeId, + 'condition_expression', + 'CASE' + ); + $edge['data']['subject_type'] = $subjectType; + $edge['data']['subject_id'] = $subjectId; + $edge['data']['condition_id'] = $ownerConditionId; + $edges[] = $edge; + } + foreach ((array)($expression['cases'] ?? []) as $index => $case) { + if (is_array($case) && is_array($case['then'] ?? null)) { + $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$case['then'], $path . '.c' . $index . '.then'); + } + } + if (is_array($expression['default'] ?? null)) { + $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$expression['default'], $path . '.default'); + } + return; + } + + $children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : []; + foreach ($children as $index => $child) { + if (is_array($child)) { + $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$child, $path . '.' . $index); + } + } + } + + private function normalizeExpressionNode(mixed $expression): array + { + if (!is_array($expression)) { + return $this->emptyV2Expression(); + } + + $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group')); + if ($type === 'predicate') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + if (!in_array($subjectType, ['question', 'condition'], true)) { + $subjectType = 'question'; + } + $operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? 'IS_TRUE')); + if (!in_array($operator, ['IS_TRUE', 'IS_FALSE', 'IS_SET', 'IS_TRUE_OR_NOT_SET', 'IS_FALSE_OR_NOT_SET'], true)) { + $operator = 'IS_TRUE'; + } + return [ + 'type' => 'predicate', + 'subject_type' => $subjectType, + 'subject_id' => (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0), + 'operator' => $operator, + ]; + } + if (in_array($type, ['branch', 'if', 'if_else'], true)) { + $branches = []; + foreach ((array)($expression['branches'] ?? []) as $index => $branch) { + if (!is_array($branch)) { + continue; + } + $kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if'))); + $isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true); + $normalized = [ + 'kind' => $isElse ? 'else' : ($index === 0 ? 'if' : 'else_if'), + 'then' => $this->normalizeExpressionNode($branch['then'] ?? $this->emptyV2Expression()), + ]; + if ($isElse) { + $normalized['else'] = true; + } else { + $normalized['when'] = $this->normalizeExpressionNode($branch['when'] ?? $this->emptyV2Expression()); + } + $branches[] = $normalized; + } + + $normalizedExpression = [ + 'type' => 'branch', + 'operator' => 'IF_ELSE', + 'branches' => $branches, + ]; + if (is_array($expression['default'] ?? null)) { + $normalizedExpression['default'] = $this->normalizeExpressionNode((array)$expression['default']); + } + return $normalizedExpression; + } + if ($type === 'case') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + if (!in_array($subjectType, ['question', 'condition'], true)) { + $subjectType = 'question'; + } + $cases = []; + foreach ((array)($expression['cases'] ?? []) as $case) { + if (!is_array($case)) { + continue; + } + $cases[] = [ + 'value' => $case['value'] ?? null, + 'then' => $this->normalizeExpressionNode($case['then'] ?? $this->emptyV2Expression()), + ]; + } + + $normalizedExpression = [ + 'type' => 'case', + 'operator' => 'CASE', + 'subject_type' => $subjectType, + 'subject_id' => (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0), + 'cases' => $cases, + ]; + if (is_array($expression['default'] ?? null)) { + $normalizedExpression['default'] = $this->normalizeExpressionNode((array)$expression['default']); + } + return $normalizedExpression; + } + + $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL')); + if (!in_array($operator, ['ALL', 'ANY'], true)) { + $operator = 'ALL'; + } + $children = []; + foreach ((array)($expression['children'] ?? []) as $child) { + if (is_array($child)) { + $children[] = $this->normalizeExpressionNode((array)$child); + } + } + + return [ + 'type' => 'group', + 'operator' => $operator, + 'children' => $children, + ]; + } + + /** + * @param array $expression + */ + private function expressionHasPredicate(array $expression, string $subjectType, int $subjectId): bool + { + $type = strtolower((string)($expression['type'] ?? 'group')); + if ($type === 'predicate') { + return strtolower((string)($expression['subject_type'] ?? '')) === $subjectType + && (int)($expression['subject_id'] ?? 0) === $subjectId; + } + if (in_array($type, ['branch', 'if', 'if_else'], true)) { + foreach ((array)($expression['branches'] ?? []) as $branch) { + if (!is_array($branch)) { + continue; + } + if (is_array($branch['when'] ?? null) && $this->expressionHasPredicate((array)$branch['when'], $subjectType, $subjectId)) { + return true; + } + if (is_array($branch['then'] ?? null) && $this->expressionHasPredicate((array)$branch['then'], $subjectType, $subjectId)) { + return true; + } + } + return is_array($expression['default'] ?? null) + && $this->expressionHasPredicate((array)$expression['default'], $subjectType, $subjectId); + } + if ($type === 'case') { + if (strtolower((string)($expression['subject_type'] ?? '')) === $subjectType + && (int)($expression['subject_id'] ?? 0) === $subjectId) { + return true; + } + foreach ((array)($expression['cases'] ?? []) as $case) { + if (is_array($case) && is_array($case['then'] ?? null) && $this->expressionHasPredicate((array)$case['then'], $subjectType, $subjectId)) { + return true; + } + } + return is_array($expression['default'] ?? null) + && $this->expressionHasPredicate((array)$expression['default'], $subjectType, $subjectId); + } + + foreach ((array)($expression['children'] ?? []) as $child) { + if (is_array($child) && $this->expressionHasPredicate((array)$child, $subjectType, $subjectId)) { + return true; + } + } + return false; + } + + /** + * @param array $expression + * @return array + */ + private function removeExpressionPredicate(array $expression, string $subjectType, int $subjectId): array + { + $expression = $this->normalizeExpressionNode($expression); + if (($expression['type'] ?? 'group') === 'predicate') { + return $this->expressionHasPredicate($expression, $subjectType, $subjectId) + ? $this->emptyV2Expression() + : $expression; + } + if (in_array(strtolower((string)($expression['type'] ?? 'group')), ['branch', 'if', 'if_else'], true)) { + foreach ((array)($expression['branches'] ?? []) as $index => $branch) { + if (!is_array($branch)) { + continue; + } + if (is_array($branch['when'] ?? null)) { + $expression['branches'][$index]['when'] = $this->removeExpressionPredicate((array)$branch['when'], $subjectType, $subjectId); + } + if (is_array($branch['then'] ?? null)) { + $expression['branches'][$index]['then'] = $this->removeExpressionPredicate((array)$branch['then'], $subjectType, $subjectId); + } + } + if (is_array($expression['default'] ?? null)) { + $expression['default'] = $this->removeExpressionPredicate((array)$expression['default'], $subjectType, $subjectId); + } + return $expression; + } + if (($expression['type'] ?? 'group') === 'case') { + if (strtolower((string)($expression['subject_type'] ?? '')) === $subjectType + && (int)($expression['subject_id'] ?? 0) === $subjectId) { + return $this->emptyV2Expression(); + } + foreach ((array)($expression['cases'] ?? []) as $index => $case) { + if (is_array($case) && is_array($case['then'] ?? null)) { + $expression['cases'][$index]['then'] = $this->removeExpressionPredicate((array)$case['then'], $subjectType, $subjectId); + } + } + if (is_array($expression['default'] ?? null)) { + $expression['default'] = $this->removeExpressionPredicate((array)$expression['default'], $subjectType, $subjectId); + } + return $expression; + } + + $children = []; + foreach ((array)($expression['children'] ?? []) as $child) { + if (!is_array($child)) { + continue; + } + $normalizedChild = $this->normalizeExpressionNode((array)$child); + if (($normalizedChild['type'] ?? '') === 'predicate' && $this->expressionHasPredicate($normalizedChild, $subjectType, $subjectId)) { + continue; + } + if (($normalizedChild['type'] ?? '') === 'group') { + $normalizedChild = $this->removeExpressionPredicate($normalizedChild, $subjectType, $subjectId); + } + $children[] = $normalizedChild; + } + + $expression['children'] = $children; + return $expression; + } + + /** + * @return array + */ + private function emptyV2Expression(): array + { + return [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [], + ]; + } + + private function caseValueLabel(mixed $value): string + { + if ($value === true) { + return 'true'; + } + if ($value === false) { + return 'false'; + } + if ($value === null) { + return 'unanswered'; + } + return (string)$value; + } + + /** + * @param array> $edges + * @param array $row + */ + private function appendScopeEdges(array &$edges, string $targetId, array $row): void + { + $scopes = [ + 'lane' => 'lane', + 'product' => 'vehicle_type', + 'machine_type_id' => 'machine_type', + ]; + foreach ($scopes as $field => $type) { + $scopeId = $this->nullableInt($row[$field] ?? null); + if ($scopeId !== null) { + $edges[] = $this->edge('scope:' . $type . ':' . $scopeId . ':' . $targetId, $type . ':' . $scopeId, $targetId, 'scope', 'scope'); + } + } + } + + /** + * @param array $layout + * @param array> $nodes + * @return array> + */ + private function applyLayoutToNodes(array $nodes, array $layout): array + { + $positions = $this->extractNodePositions((array)($layout['nodes'] ?? [])); + foreach ($nodes as &$node) { + $id = (string)($node['id'] ?? ''); + if (isset($positions[$id])) { + $node['position'] = $positions[$id]; + } + } + unset($node); + return array_values($nodes); + } + + /** + * @param array $nodes + * @return array + */ + private function extractNodePositions(array $nodes): array + { + $positions = []; + foreach ($nodes as $key => $node) { + if (is_array($node) && isset($node['id'], $node['position']) && is_array($node['position'])) { + $positions[(string)$node['id']] = [ + 'x' => (float)($node['position']['x'] ?? 0), + 'y' => (float)($node['position']['y'] ?? 0), + ]; + continue; + } + if (is_string($key) && is_array($node)) { + $positions[$key] = [ + 'x' => (float)($node['x'] ?? $node['position']['x'] ?? 0), + 'y' => (float)($node['y'] ?? $node['position']['y'] ?? 0), + ]; + } + } + + return $positions; + } + + /** + * @param array $validation + * @return array> + */ + private function buildValidationItems(array $validation): array + { + $items = []; + foreach ((array)($validation['errors'] ?? []) as $message) { + $items[] = [ + 'severity' => 'error', + 'message' => (string)$message, + ]; + } + foreach ((array)($validation['warnings'] ?? []) as $message) { + $items[] = [ + 'severity' => 'warning', + 'message' => (string)$message, + ]; + } + return $items; + } + + /** + * @param array $lookups + * @return array + */ + private function buildSimulatorDefaults(int $departmentId, array $lookups, array $gatewayWorkspace = []): array + { + $lane = $this->lookupRows($lookups, 'lanes')[0] ?? null; + $vehicleType = $this->lookupRows($lookups, 'vehicle_types')[0] ?? null; + $hasVirtualHardware = (bool)($gatewayWorkspace['virtual']['has_virtual_hardware'] ?? false); + return [ + 'department' => $departmentId, + 'lane_id' => is_array($lane) ? (int)($lane['id'] ?? 0) : null, + 'vehicle_type_id' => is_array($vehicleType) ? (int)($vehicleType['id'] ?? 0) : null, + 'reg' => 'TEST123', + 'customer_number' => null, + 'hardware_mode' => $hasVirtualHardware ? 'studio' : 'real', + ]; + } + + /** + * @param array $data + * @return array + */ + private function node(string $id, string $type, string $label, string $kind, array $data, int $x, int $y): array + { + $data['kind'] = $kind; + $data['label'] = $label; + return [ + 'id' => $id, + 'type' => $type, + 'position' => [ + 'x' => $x, + 'y' => $y, + ], + 'data' => $data, + ]; + } + + /** + * @return array + */ + private function edge(string $id, string $source, string $target, string $kind, string $label): array + { + return [ + 'id' => $id, + 'source' => $source, + 'target' => $target, + 'type' => 'smoothstep', + 'label' => $label, + 'data' => [ + 'kind' => $kind, + ], + ]; + } + + /** + * @param array $row + * @param array $lookups + */ + private function entityLabel(string $entity, int $id, array $row, array $lookups): string + { + $field = match ($entity) { + 'question' => 'question', + 'condition', 'rule' => 'name', + 'task' => 'task', + 'action' => 'name', + default => 'label', + }; + $label = trim((string)($row[$field] ?? '')); + if ($label !== '') { + return $label; + } + return $this->labelFor($entity . 's', $id, $lookups); + } + + /** + * @param array $action + * @param array $lookups + */ + private function actionSubtitle(array $action, array $lookups): string + { + $parts = [ + selfserve_studio_actions::eventLabel((string)($action['event'] ?? '')), + ucfirst((string)($action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH)), + selfserve_studio_actions::operationLabel((string)($action['operation'] ?? ''), $action['relay_state'] ?? null), + ]; + $scope = $this->scopeLabel($action, $lookups); + if ($scope !== 'Shared scope') { + $parts[] = $scope; + } + return implode(' / ', array_filter($parts, static fn(string $part): bool => $part !== '')); + } + + /** + * @param array $lookups + */ + private function labelFor(string $lookupType, mixed $id, array $lookups): string + { + $id = $this->nullableInt($id); + if ($id === null) { + return 'All'; + } + $labels = is_array($lookups['labels'][$lookupType] ?? null) ? (array)$lookups['labels'][$lookupType] : []; + return (string)($labels[(string)$id] ?? ucfirst(str_replace('_', ' ', rtrim($lookupType, 's'))) . ' ' . $id); + } + + /** + * @param array $lookups + * @return array> + */ + private function lookupRows(array $lookups, string $type): array + { + return isset($lookups[$type]) && is_array($lookups[$type]) ? array_values((array)$lookups[$type]) : []; + } + + /** + * @param array> $rows + * @return array> + */ + private function sortedRows(array $rows, array $fields): array + { + usort($rows, static function (array $left, array $right) use ($fields): int { + foreach ($fields as $field) { + $leftValue = $left[$field] ?? null; + $rightValue = $right[$field] ?? null; + if (is_numeric($leftValue) && is_numeric($rightValue)) { + $comparison = (int)$leftValue <=> (int)$rightValue; + } else { + $comparison = strcmp((string)$leftValue, (string)$rightValue); + } + if ($comparison !== 0) { + return $comparison; + } + } + return 0; + }); + return array_values($rows); + } + + /** + * @param array $task + * @return array + */ + private function normalizeTaskPayload(array $task): array + { + $task['services'] = $this->normalizeServiceList($task['services'] ?? []); + $task['buttons'] = $this->normalizeArrayPayload($task['buttons'] ?? []); + $task['attachments'] = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : []; + return $task; + } + + /** + * @param array $config + * @return array + */ + private function withTaskAttachments(array $config): array + { + if (!is_array($config['tasks'] ?? null)) { + return $config; + } + + $config['tasks'] = (new selfserve_task_attachment_payloads())->attachToTasks(array_values((array)$config['tasks'])); + return $config; + } + + /** + * @param array $workspace + * @return array> + */ + private function relayServicesFromWorkspace(array $workspace): array + { + $servicesByRelay = []; + foreach ((array)($workspace['lanes'] ?? []) as $lane) { + if (!is_array($lane)) { + continue; + } + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (!is_array($slot)) { + continue; + } + $relayId = trim((string)($slot['relay_id'] ?? '')); + $service = $this->normalizeServiceName($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? ''); + if ($relayId === '' || $service === '') { + continue; + } + $servicesByRelay[$relayId][$service] = true; + } + } + + return array_map(static fn(array $services): array => array_keys($services), $servicesByRelay); + } + + /** + * @param array $binding + * @param array> $relayServices + * @return array + */ + private function bindingServices(array $binding, string $relayId, array $relayServices): array + { + $services = []; + foreach (['role', 'service', 'slot'] as $field) { + $service = $this->normalizeServiceName($binding[$field] ?? ''); + if ($service !== '') { + $services[$service] = true; + } + } + foreach ($this->normalizeServiceList($binding['services'] ?? []) as $service) { + $services[$service] = true; + } + foreach ((array)($relayServices[$relayId] ?? []) as $service) { + $normalized = $this->normalizeServiceName($service); + if ($normalized !== '') { + $services[$normalized] = true; + } + } + + return array_keys($services); + } + + /** + * @param array $gateway + */ + private function gatewayIdentifier(array $gateway): string + { + return trim((string)($gateway['key'] ?? $gateway['gateway_key'] ?? $gateway['id'] ?? '')); + } + + /** + * @param array $gateway + */ + private function gatewayNodeId(array $gateway): string + { + $nodeId = trim((string)($gateway['node_id'] ?? '')); + return $nodeId !== '' ? $nodeId : 'gateway:' . $this->gatewayIdentifier($gateway); + } + + /** + * @param array $binding + */ + private function bindingNodeId(string $gatewayId, string $relayId, int $bindingIndex, array $binding): string + { + $nodeId = trim((string)($binding['node_id'] ?? '')); + return $nodeId !== '' ? $nodeId : 'binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex; + } + + /** + * @param array $workspace + * @return array> + */ + private function gatewayBindingReferences(array $workspace): array + { + $relayServices = $this->relayServicesFromWorkspace($workspace); + $references = []; + foreach ((array)($workspace['gateways'] ?? []) as $gateway) { + if (!is_array($gateway)) { + continue; + } + $gatewayId = $this->gatewayIdentifier($gateway); + if ($gatewayId === '') { + continue; + } + foreach ((array)($gateway['bindings'] ?? []) as $bindingIndex => $binding) { + if (!is_array($binding)) { + continue; + } + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + $services = $this->bindingServices($binding, $relayId, $relayServices); + if ($services === []) { + continue; + } + $references[] = [ + 'gateway_id' => $gatewayId, + 'relay_id' => $relayId, + 'binding_index' => (int)$bindingIndex, + 'node_id' => $this->bindingNodeId($gatewayId, $relayId, (int)$bindingIndex, $binding), + 'services' => $services, + 'virtual' => (bool)($gateway['virtual'] ?? $binding['virtual'] ?? false), + ]; + } + } + + return $references; + } + + private function serviceForBindingNode(int $departmentId, string $nodeId): string + { + $workspace = $this->buildGatewayWorkspace($departmentId); + foreach ($this->gatewayBindingReferences($workspace) as $binding) { + if ((string)$binding['node_id'] === $nodeId) { + return (string)($binding['services'][0] ?? ''); + } + } + return ''; + } + + private function updateTaskServiceConnection(int $departmentId, int $taskId, string $service, bool $disconnect): void + { + if ($taskId <= 0 || $service === '') { + return; + } + + $pdo = db::getPDO(); + $statement = $pdo->prepare( + 'SELECT services FROM department_selfserve_tasks WHERE id = :id AND department IN (0, :department) LIMIT 1' + ); + $statement->execute([ + ':id' => $taskId, + ':department' => $departmentId, + ]); + $row = $statement->fetch(\PDO::FETCH_ASSOC); + if (!is_array($row)) { + throw new \RuntimeException('Task is not available in the selected department.'); + } + + $services = $this->normalizeServiceList($row['services'] ?? []); + $serviceSet = array_fill_keys($services, true); + if ($disconnect) { + unset($serviceSet[$service]); + } else { + $serviceSet[$service] = true; + } + + $pdo->prepare( + 'UPDATE department_selfserve_tasks SET services = :services WHERE id = :id AND department IN (0, :department)' + )->execute([ + ':services' => $this->jsonArray(array_keys($serviceSet)), + ':id' => $taskId, + ':department' => $departmentId, + ]); + } + + /** + * @return array + */ + private function normalizeArrayPayload(mixed $value): array + { + if (is_string($value)) { + $decoded = json_decode($value, true); + $value = is_array($decoded) ? $decoded : array_filter(array_map('trim', explode(',', $value)), static fn(string $item): bool => $item !== ''); + } + return is_array($value) ? array_values($value) : []; + } + + /** + * @return array + */ + private function normalizeServiceList(mixed $value): array + { + $services = []; + foreach ($this->normalizeArrayPayload($value) as $entry) { + $service = $this->normalizeServiceName($entry); + if ($service !== '') { + $services[$service] = true; + } + } + return array_keys($services); + } + + private function normalizeServiceName(mixed $value): string + { + return strtoupper(trim((string)$value)); + } + + /** + * @param array $answers + * @return array + */ + private function pathAnswerOverrides(array $answers): array + { + ksort($answers, SORT_NUMERIC); + $overrides = []; + foreach ($answers as $questionId => $answer) { + if ($answer !== true && $answer !== false) { + continue; + } + $overrides[] = [ + 'question_id' => (int)$questionId, + 'value' => $answer, + ]; + } + return $overrides; + } + + /** + * @param array $simulation + * @param array $answers + * @return array|null + */ + private function nextPathQuestion(array $simulation, array $answers): ?array + { + $debugQuestions = is_array($simulation['debug']['questions'] ?? null) ? (array)$simulation['debug']['questions'] : []; + foreach ($debugQuestions as $question) { + if (!is_array($question)) { + continue; + } + $questionId = (int)($question['id'] ?? 0); + if ($questionId <= 0 || array_key_exists($questionId, $answers)) { + continue; + } + $visible = !array_key_exists('visible', $question) || (bool)$question['visible'] === true; + $answer = $question['answer'] ?? null; + if ($visible && $answer !== true && $answer !== false) { + return $question; + } + } + + $visibleQuestions = is_array($simulation['questions'] ?? null) ? (array)$simulation['questions'] : []; + foreach ($visibleQuestions as $question) { + if (!is_array($question)) { + continue; + } + $questionId = (int)($question['id'] ?? 0); + if ($questionId <= 0 || array_key_exists($questionId, $answers)) { + continue; + } + $answer = $question['answer'] ?? null; + if ($answer !== true && $answer !== false) { + return [ + 'id' => $questionId, + 'label' => (string)($question['question'] ?? ('Question ' . $questionId)), + 'node_id' => 'question:' . $questionId, + 'answer' => $answer, + 'visible' => true, + ]; + } + } + + return null; + } + + private function pathLimit(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + + $parsed = (int)$value; + return $parsed > 0 ? $parsed : null; + } + + /** + * @param array $scope + * @param array> $groups + * @param array> $paths + * @param array $questionIds + * @return array + */ + private function pathOutcomesProjectionPayload( + array $scope, + array $groups, + array $paths, + bool $truncated, + ?int $maxStates, + int $stateCount, + int $terminalPathCount, + array $questionIds, + int $pendingStateCount, + array $confirmationRows = [] + ): array { + $warnings = []; + if ($truncated && $maxStates !== null) { + $warnings[] = 'Path projection was truncated at ' . $maxStates . ' explored state(s).'; + } + + $knownStateCount = max(1, $stateCount + $pendingStateCount); + $complete = !$truncated && $pendingStateCount === 0; + $percent = $complete ? 100 : min(99, max(1, (int)floor(($stateCount / $knownStateCount) * 100))); + + return $this->pathOutcomesPayload( + $scope, + $this->finalizePathOutcomeGroups($groups), + $paths, + $warnings, + $truncated, + $maxStates, + $stateCount, + $terminalPathCount, + $questionIds, + [ + 'complete' => $complete, + 'percent' => $percent, + 'state_count' => $stateCount, + 'pending_state_count' => $pendingStateCount, + 'terminal_path_count' => $terminalPathCount, + ], + $confirmationRows + ); + } + + /** + * @param array $scope + * @param array> $outcomes + * @param array> $paths + * @param array $warnings + * @param array|array $questionIds + * @param array $progress + * @return array + */ + private function pathOutcomesPayload( + array $scope, + array $outcomes, + array $paths, + array $warnings, + bool $truncated, + ?int $maxStates, + int $stateCount, + int $terminalPathCount, + array $questionIds, + array $progress = [], + ?array $confirmationRows = null + ): array { + $confirmationRows = $confirmationRows ?? []; + + usort($outcomes, static fn(array $left, array $right): int => ((int)($right['path_count'] ?? 0) <=> (int)($left['path_count'] ?? 0)) + ?: strcmp((string)($left['summary'] ?? ''), (string)($right['summary'] ?? ''))); + foreach ($outcomes as $index => &$outcome) { + $outcome['id'] = 'outcome-' . ($index + 1); + } + unset($outcome); + + foreach ($paths as $index => &$path) { + $path['id'] = 'path-' . ($index + 1); + } + unset($path); + + $confirmations = $this->applyPathConfirmations($paths, $confirmationRows); + + $questionIdValues = []; + foreach ($questionIds as $key => $value) { + $questionIdValues[] = $value === true ? (int)$key : (int)$value; + } + $questionIdValues = array_values(array_unique(array_filter($questionIdValues, static fn(int $id): bool => $id > 0))); + sort($questionIdValues); + + $progress = array_merge([ + 'complete' => !$truncated, + 'percent' => $truncated ? 99 : 100, + 'state_count' => $stateCount, + 'pending_state_count' => 0, + 'terminal_path_count' => $terminalPathCount, + ], $progress); + + return [ + 'scope' => $scope, + 'summary' => [ + 'state_count' => $stateCount, + 'terminal_path_count' => $terminalPathCount, + 'outcome_count' => count($outcomes), + 'question_count' => count($questionIdValues), + 'question_ids' => $questionIdValues, + 'max_states' => $maxStates, + 'path_sample_count' => count($paths), + 'confirmations' => $confirmations['summary'], + ], + 'outcomes' => array_values($outcomes), + 'paths' => array_values($paths), + 'confirmations' => $confirmations, + 'warnings' => array_values(array_unique($warnings)), + 'truncated' => $truncated, + 'progress' => $progress, + ]; + } + + /** + * @param array> $paths + * @param array> $confirmationRows + * @return array{summary:array,removed:array>} + */ + private function applyPathConfirmations(array &$paths, array $confirmationRows): array + { + $rowsBySignature = []; + foreach ($confirmationRows as $row) { + if (!is_array($row)) { + continue; + } + $signature = trim((string)($row['path_signature'] ?? '')); + if ($signature !== '') { + $rowsBySignature[$signature] = $row; + } + } + + $matched = []; + $summary = [ + 'confirmed' => 0, + 'unconfirmed' => 0, + 'stale' => 0, + 'removed' => 0, + 'total' => count($paths), + ]; + + foreach ($paths as &$path) { + if (!is_array($path)) { + continue; + } + $pathSignature = $this->pathSignature( + is_array($path['scope'] ?? null) ? (array)$path['scope'] : [], + is_array($path['answers'] ?? null) ? (array)$path['answers'] : [] + ); + $resultSignature = $this->pathResultSignature($path); + $path['path_signature'] = $pathSignature; + $path['result_signature'] = $resultSignature; + $path['confirmation_status'] = 'unconfirmed'; + $path['confirmed_at'] = null; + $path['confirmed_by'] = null; + $path['stale_reason'] = null; + + $row = $rowsBySignature[$pathSignature] ?? null; + if (is_array($row)) { + $matched[$pathSignature] = true; + $path['confirmed_at'] = $row['confirmed_at'] ?? null; + $path['confirmed_by'] = $row['confirmed_by'] ?? null; + if ((string)($row['result_signature'] ?? '') === $resultSignature) { + $path['confirmation_status'] = 'confirmed'; + } else { + $path['confirmation_status'] = 'stale'; + $path['stale_reason'] = 'Result changed since confirmation.'; + } + } + + $summary[(string)$path['confirmation_status']]++; + } + unset($path); + + $removed = []; + foreach ($rowsBySignature as $signature => $row) { + if (isset($matched[$signature])) { + continue; + } + $removed[] = [ + 'id' => $row['id'] ?? null, + 'path_signature' => $signature, + 'result_signature' => (string)($row['result_signature'] ?? ''), + 'confirmation_status' => 'stale', + 'stale_reason' => 'Path no longer appears in the projected cases.', + 'answers' => is_array($row['answers'] ?? null) ? $row['answers'] : [], + 'result' => is_array($row['result'] ?? null) ? $row['result'] : [], + 'scope' => is_array($row['scope'] ?? null) ? $row['scope'] : [], + 'confirmed_at' => $row['confirmed_at'] ?? null, + 'confirmed_by' => $row['confirmed_by'] ?? null, + ]; + } + $summary['removed'] = count($removed); + + return [ + 'summary' => $summary, + 'removed' => $removed, + ]; + } + + /** + * @param array> $groups + * @param array $simulation + * @param array> $chain + * @param array $scope + */ + private function addPathOutcomeGroup(array &$groups, array $simulation, array $chain, array $scope, int $sampleLimit): void + { + $tasks = $this->pathActiveTasks($simulation); + $services = $this->pathServices($simulation, $tasks); + $signals = $this->pathSignals($simulation); + $allowed = (bool)($simulation['allowed'] ?? false); + $key = $this->stableJson([ + 'scope' => $this->pathScopeKey($scope), + 'allowed' => $allowed, + 'services' => $services, + 'tasks' => array_map(static fn(array $task): array => [ + 'id' => (int)($task['id'] ?? 0), + 'services' => (array)($task['services'] ?? []), + ], $tasks), + 'signals' => $signals, + ]); + + if (!isset($groups[$key])) { + $groups[$key] = [ + 'path_count' => 0, + 'allowed' => $allowed, + 'services' => $services, + 'tasks' => $tasks, + 'signals' => $signals, + 'sample_chains' => [], + 'scopes' => [], + 'node_ids' => [], + 'summary' => $this->pathOutcomeSummary($allowed, $services, $tasks, $signals), + ]; + } + + $groups[$key]['path_count'] = (int)$groups[$key]['path_count'] + 1; + $scopeKey = $this->stableJson($this->pathScopeKey($scope)); + $groups[$key]['scopes'][$scopeKey] = $scope; + if (count((array)$groups[$key]['sample_chains']) < $sampleLimit) { + $groups[$key]['sample_chains'][] = [ + 'scope' => $scope, + 'answers' => array_values($chain), + ]; + } + + foreach ($this->pathNodeIds($chain, $tasks, $signals) as $nodeId) { + $groups[$key]['node_ids'][$nodeId] = true; + } + } + + /** + * @param array $simulation + * @param array> $chain + * @param array $scope + * @return array + */ + private function pathResultFromSimulation(array $simulation, array $chain, array $scope): array + { + $tasks = $this->pathActiveTasks($simulation); + $services = $this->pathServices($simulation, $tasks); + $signals = $this->pathSignals($simulation); + $allowed = (bool)($simulation['allowed'] ?? false); + + return [ + 'id' => '', + 'result' => $allowed ? 'Allowed' : 'Blocked', + 'summary' => $this->pathOutcomeSummary($allowed, $services, $tasks, $signals), + 'allowed' => $allowed, + 'services' => $services, + 'tasks' => $tasks, + 'signals' => $signals, + 'task_count' => count($tasks), + 'signal_count' => count($signals), + 'answers' => array_values($chain), + 'scope' => $scope, + 'node_ids' => $this->pathNodeIds($chain, $tasks, $signals), + ]; + } + + /** + * @param array> $chain + * @param array> $tasks + * @param array> $signals + * @return array + */ + private function pathNodeIds(array $chain, array $tasks, array $signals): array + { + $nodeIds = []; + foreach ($chain as $answer) { + if (is_array($answer) && trim((string)($answer['node_id'] ?? '')) !== '') { + $nodeIds[(string)$answer['node_id']] = true; + } + } + foreach ($tasks as $task) { + if (trim((string)($task['node_id'] ?? '')) !== '') { + $nodeIds[(string)$task['node_id']] = true; + } + } + foreach ($signals as $signal) { + foreach (['target_binding', 'target_relay_node_id', 'target_gateway_node_id'] as $field) { + if (trim((string)($signal[$field] ?? '')) !== '') { + $nodeIds[(string)$signal[$field]] = true; + } + } + } + + $nodeIds = array_keys($nodeIds); + sort($nodeIds); + return $nodeIds; + } + + /** + * @param array> $groups + * @return array> + */ + private function finalizePathOutcomeGroups(array $groups): array + { + $outcomes = []; + foreach ($groups as $group) { + $scopes = array_values((array)($group['scopes'] ?? [])); + $nodeIds = array_values(array_keys((array)($group['node_ids'] ?? []))); + sort($nodeIds); + $outcomes[] = [ + 'id' => '', + 'summary' => (string)($group['summary'] ?? ''), + 'path_count' => (int)($group['path_count'] ?? 0), + 'allowed' => (bool)($group['allowed'] ?? false), + 'services' => array_values((array)($group['services'] ?? [])), + 'tasks' => array_values((array)($group['tasks'] ?? [])), + 'signals' => array_values((array)($group['signals'] ?? [])), + 'sample_chains' => array_values((array)($group['sample_chains'] ?? [])), + 'scopes' => $scopes, + 'node_ids' => $nodeIds, + ]; + } + + usort($outcomes, static fn(array $left, array $right): int => ((int)($right['path_count'] ?? 0) <=> (int)($left['path_count'] ?? 0)) + ?: strcmp((string)($left['summary'] ?? ''), (string)($right['summary'] ?? ''))); + foreach ($outcomes as $index => &$outcome) { + $outcome['id'] = 'outcome-' . ($index + 1); + } + unset($outcome); + return $outcomes; + } + + /** + * @param array $simulation + * @return array> + */ + private function pathActiveTasks(array $simulation): array + { + $tasks = []; + $debugTasks = is_array($simulation['debug']['tasks'] ?? null) ? (array)$simulation['debug']['tasks'] : []; + foreach ($debugTasks as $task) { + if (!is_array($task) || (bool)($task['active'] ?? false) !== true) { + continue; + } + $tasks[] = [ + 'id' => (int)($task['id'] ?? 0), + 'node_id' => (string)($task['node_id'] ?? ('task:' . (int)($task['id'] ?? 0))), + 'label' => (string)($task['label'] ?? $task['task'] ?? ('Task ' . (int)($task['id'] ?? 0))), + 'services' => $this->normalizeServiceList($task['services'] ?? []), + 'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []), + 'order_priority' => (int)($task['order_priority'] ?? 0), + ]; + } + if ($tasks !== []) { + return $tasks; + } + + foreach ((array)($simulation['tasks'] ?? []) as $task) { + if (!is_array($task)) { + continue; + } + $taskId = (int)($task['id'] ?? $task['task_id'] ?? 0); + $tasks[] = [ + 'id' => $taskId, + 'node_id' => 'task:' . $taskId, + 'label' => (string)($task['task'] ?? $task['label'] ?? ('Task ' . $taskId)), + 'services' => $this->normalizeServiceList($task['services'] ?? []), + 'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []), + 'order_priority' => (int)($task['order_priority'] ?? 0), + ]; + } + usort($tasks, static fn(array $a, array $b): int => ((int)($a['order_priority'] ?? 0) <=> (int)($b['order_priority'] ?? 0)) + ?: ((int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0))); + return $tasks; + } + + /** + * @param array $simulation + * @param array> $tasks + * @return array + */ + private function pathServices(array $simulation, array $tasks): array + { + $services = []; + foreach ((array)($simulation['allowed_services'] ?? []) as $service) { + $normalized = $this->normalizeServiceName($service); + if ($normalized !== '') { + $services[$normalized] = true; + } + } + foreach ($tasks as $task) { + foreach ($this->normalizeServiceList($task['services'] ?? []) as $service) { + $services[$service] = true; + } + } + $values = array_keys($services); + sort($values); + return $values; + } + + /** + * @param array $simulation + * @return array> + */ + private function pathSignals(array $simulation): array + { + $timeline = is_array($simulation['debug']['signal_timeline'] ?? null) + ? (array)$simulation['debug']['signal_timeline'] + : (is_array($simulation['debug']['hardware']['signal_timeline'] ?? null) ? (array)$simulation['debug']['hardware']['signal_timeline'] : []); + $signals = []; + foreach ($timeline as $index => $signal) { + if (!is_array($signal)) { + continue; + } + $signals[] = [ + 'sequence' => (int)($signal['sequence'] ?? ($index + 1)), + 'runtime_stage' => (string)($signal['runtime_stage'] ?? ''), + 'signal_type' => (string)($signal['signal_type'] ?? ''), + 'relay_role' => (string)($signal['relay_role'] ?? ''), + 'relay_id' => $signal['relay_id'] ?? null, + 'target_gateway_label' => $signal['target_gateway_label'] ?? null, + 'target_binding' => $signal['target_binding'] ?? null, + 'target_gateway_node_id' => $signal['target_gateway_node_id'] ?? null, + 'target_relay_node_id' => $signal['target_relay_node_id'] ?? null, + 'source' => (string)($signal['source'] ?? ''), + 'virtual' => (bool)($signal['virtual'] ?? false), + 'predicted_status' => (string)($signal['predicted_status'] ?? ''), + 'payload' => $this->sortStableValue(is_array($signal['payload'] ?? null) ? (array)$signal['payload'] : []), + 'skip_block_reason' => $signal['skip_block_reason'] ?? null, + ]; + } + return $signals; + } + + /** + * @param array $scope + * @return array + */ + private function pathScopeKey(array $scope): array + { + return [ + 'department_id' => $scope['department_id'] ?? null, + 'lane_id' => $scope['lane_id'] ?? null, + 'vehicle_type_id' => $scope['vehicle_type_id'] ?? null, + 'machine_type_id' => $scope['machine_type_id'] ?? null, + 'config_source' => $scope['config_source'] ?? null, + 'config_version_id' => $scope['config_version_id'] ?? null, + 'hardware_mode' => $scope['hardware_mode'] ?? null, + ]; + } + + /** + * @param array $path + */ + private function pathResultSignature(array $path): string + { + return hash('sha256', $this->stableJson([ + 'allowed' => (bool)($path['allowed'] ?? false), + 'services' => $this->normalizeServiceList($path['services'] ?? []), + 'tasks' => array_values(array_map(function (array $task): array { + return [ + 'id' => (int)($task['id'] ?? 0), + 'label' => (string)($task['label'] ?? $task['task'] ?? ''), + 'services' => $this->normalizeServiceList($task['services'] ?? []), + 'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []), + 'order_priority' => (int)($task['order_priority'] ?? 0), + ]; + }, array_values((array)($path['tasks'] ?? [])))), + 'buttons' => array_values(array_reduce( + array_values((array)($path['tasks'] ?? [])), + function (array $carry, mixed $task): array { + if (!is_array($task)) { + return $carry; + } + foreach ($this->normalizeArrayPayload($task['buttons'] ?? []) as $button) { + $key = (is_int($button) ? 'int:' : 'string:') . (string)$button; + $carry[$key] = $button; + } + return $carry; + }, + [] + )), + 'signals' => array_values(array_map(static fn(array $signal): array => [ + 'runtime_stage' => (string)($signal['runtime_stage'] ?? ''), + 'signal_type' => (string)($signal['signal_type'] ?? ''), + 'relay_role' => (string)($signal['relay_role'] ?? ''), + 'relay_id' => $signal['relay_id'] ?? null, + 'target_binding' => $signal['target_binding'] ?? null, + 'source' => (string)($signal['source'] ?? ''), + 'predicted_status' => (string)($signal['predicted_status'] ?? ''), + 'payload' => is_array($signal['payload'] ?? null) ? (array)$signal['payload'] : [], + ], array_values((array)($path['signals'] ?? [])))), + ])); + } + + /** + * @return array> + */ + private function loadPathConfirmationRows(int $departmentId, ?int $configVersionId, int $laneId, ?int $vehicleTypeId, string $configSource): array + { + if (!$this->tableExists('department_selfserve_path_confirmations')) { + return []; + } + + $where = [ + 'department_id = :department_id', + 'lane_id = :lane_id', + 'config_source = :config_source', + 'deleted_at IS NULL', + ]; + $params = [ + ':department_id' => $departmentId, + ':lane_id' => $laneId, + ':config_source' => $configSource, + ]; + if ($configVersionId === null) { + $where[] = 'config_version_id IS NULL'; + } else { + $where[] = 'config_version_id = :config_version_id'; + $params[':config_version_id'] = $configVersionId; + } + if ($vehicleTypeId !== null) { + $where[] = 'vehicle_type_id = :vehicle_type_id'; + $params[':vehicle_type_id'] = $vehicleTypeId; + } + + $statement = db::getPDO()->prepare( + 'SELECT * + FROM department_selfserve_path_confirmations + WHERE ' . implode(' AND ', $where) . ' + ORDER BY confirmed_at DESC, id DESC' + ); + $statement->execute($params); + $rows = $statement->fetchAll(\PDO::FETCH_ASSOC) ?: []; + + return array_values(array_map(function (array $row): array { + $row['answers'] = $this->decodeJsonArray($row['answers_json'] ?? null); + $row['result'] = $this->decodeJsonArray($row['result_json'] ?? null); + $row['scope'] = $this->decodeJsonArray($row['scope_json'] ?? null); + return $row; + }, $rows)); + } + + /** + * @return array|array + */ + private function decodeJsonArray(mixed $value): array + { + if (is_array($value)) { + return $value; + } + $decoded = json_decode((string)($value ?? '[]'), true); + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array $services + * @param array> $tasks + * @param array> $signals + */ + private function pathOutcomeSummary(bool $allowed, array $services, array $tasks, array $signals): string + { + $serviceLabel = $services === [] ? 'No services' : implode(', ', $services); + $taskText = count($tasks) === 1 ? '1 task' : count($tasks) . ' tasks'; + $signalText = count($signals) === 1 ? '1 signal' : count($signals) . ' signals'; + return ($allowed ? 'Allowed' : 'Blocked') . ' / ' . $serviceLabel . ' / ' . $taskText . ' / ' . $signalText; + } + + private function stableJson(mixed $value): string + { + $json = json_encode($this->sortStableValue($value), JSON_UNESCAPED_UNICODE); + if ($json === false) { + return ''; + } + return $json; + } + + private function sortStableValue(mixed $value): mixed + { + if (!is_array($value)) { + return $value; + } + + $isList = array_keys($value) === range(0, count($value) - 1); + if (!$isList) { + ksort($value); + } + foreach ($value as $key => $item) { + $value[$key] = $this->sortStableValue($item); + } + return $value; + } + + /** + * @param array> $rows + * @return array> + */ + private function vehicleTypeRowsFromProducts(array $rows): array + { + $vehicleTypes = []; + foreach ($this->labelRows($rows, 'name') as $row) { + $productId = (int)($row['id'] ?? 0); + if ($productId <= 0 || (int)($row['is_wash'] ?? 0) !== 1 || (int)($row['subscription_allowed'] ?? 0) !== 1) { + continue; + } + + $row['id'] = $productId; + $row['product'] = $productId; + $row['product_id'] = $productId; + $row['source'] = 'products'; + $vehicleTypes[] = $row; + } + + return $vehicleTypes; + } + + /** + * @param array> $rows + * @return array> + */ + private function labelRows(array $rows, string $labelField): array + { + return array_map(static function (array $row) use ($labelField): array { + $row['label'] = trim((string)($row[$labelField] ?? '')) ?: (string)($row['id'] ?? ''); + return $row; + }, $rows); + } + + /** + * @param array> $rows + * @return array> + */ + private function configLabelRows(array $rows, string $labelField): array + { + return array_map(static function (array $row) use ($labelField): array { + $row['label'] = trim((string)($row[$labelField] ?? '')) ?: (string)($row['id'] ?? ''); + return [ + 'id' => (int)($row['id'] ?? 0), + 'label' => $row['label'], + 'raw' => $row, + ]; + }, $rows); + } + + /** + * @param array> $gateways + * @return array> + */ + private function gatewayLabelRows(array $gateways): array + { + $rows = []; + foreach ($gateways as $gateway) { + if (is_array($gateway)) { + $gatewayId = $this->gatewayIdentifier($gateway); + if ($gatewayId === '') { + continue; + } + $rows[] = [ + 'id' => $gatewayId, + 'label' => (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)), + 'status' => (string)($gateway['status'] ?? 'UNKNOWN'), + 'virtual' => (bool)($gateway['virtual'] ?? false), + 'raw' => $gateway, + ]; + } + } + return $rows; + } + + /** + * @param array> $relays + * @return array> + */ + private function relayLabelRows(array $relays): array + { + $rows = []; + foreach ($relays as $relay) { + if (!is_array($relay)) { + continue; + } + $relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? '')); + if ($relayId === '') { + continue; + } + $rows[] = [ + 'id' => $relayId, + 'label' => (string)($relay['name'] ?? ('Relay ' . $relayId)), + 'raw' => $relay, + ]; + } + return $rows; + } + + /** + * @param array> $gateways + * @return array> + */ + private function bindingLabelRows(array $gateways): array + { + $rows = []; + foreach ($gateways as $gateway) { + if (!is_array($gateway)) { + continue; + } + $gatewayId = $this->gatewayIdentifier($gateway); + foreach ((array)($gateway['bindings'] ?? []) as $index => $binding) { + if (!is_array($binding)) { + continue; + } + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($gatewayId === '' || $relayId === '') { + continue; + } + $services = $this->bindingServices($binding, $relayId, []); + $rows[] = [ + 'id' => $gatewayId . ':' . $relayId . ':' . $index, + 'label' => (string)($binding['label'] ?? ('Gateway ' . $gatewayId . ' relay ' . $relayId)), + 'gateway_id' => $gatewayId, + 'relay_id' => $relayId, + 'role' => (string)($binding['role'] ?? ''), + 'services' => $services, + 'virtual' => (bool)($gateway['virtual'] ?? $binding['virtual'] ?? false), + ]; + } + } + return $rows; + } + + /** + * @return array> + */ + private function fetchRows(string $table, array $columns, array $where): array + { + if (!$this->tableExists($table)) { + return []; + } + $availableColumns = $this->tableColumns($table); + $columns = array_values(array_filter($columns, static fn(string $column): bool => in_array($column, $availableColumns, true))); + if ($columns === []) { + return []; + } + + $conditions = []; + $params = []; + foreach ($where as $field => $value) { + if (!in_array($field, $availableColumns, true)) { + continue; + } + $conditions[] = '`' . $field . '` = :' . $field; + $params[':' . $field] = $value; + } + if (in_array('deleted_at', $availableColumns, true)) { + $conditions[] = '`deleted_at` IS NULL'; + } + $sql = 'SELECT `' . implode('`, `', $columns) . '` FROM `' . $table . '`'; + if ($conditions !== []) { + $sql .= ' WHERE ' . implode(' AND ', $conditions); + } + if (in_array('order_priority', $availableColumns, true)) { + $sql .= ' ORDER BY `order_priority` ASC, `id` ASC'; + } elseif (in_array('id', $availableColumns, true)) { + $sql .= ' ORDER BY `id` ASC'; + } + + $statement = db::getPDO()->prepare($sql); + $statement->execute($params); + return $statement->fetchAll(\PDO::FETCH_ASSOC) ?: []; + } + + private function tableExists(string $table): bool + { + $statement = db::getPDO()->prepare( + 'SELECT COUNT(*) AS c FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table' + ); + $statement->execute([':table' => $table]); + return (int)($statement->fetch(\PDO::FETCH_ASSOC)['c'] ?? 0) > 0; + } + + /** + * @return array + */ + private function tableColumns(string $table): array + { + if (isset($this->columnCache[$table])) { + return $this->columnCache[$table]; + } + $statement = db::getPDO()->prepare( + 'SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table' + ); + $statement->execute([':table' => $table]); + $this->columnCache[$table] = array_map( + static fn(array $row): string => (string)$row['COLUMN_NAME'], + $statement->fetchAll(\PDO::FETCH_ASSOC) ?: [] + ); + return $this->columnCache[$table]; + } + + private function normalizeEntity(string $entity): string + { + $entity = strtolower(trim($entity)); + return match ($entity) { + 'questions' => 'question', + 'conditions' => 'condition', + 'rules' => 'rule', + 'tasks' => 'task', + 'actions' => 'action', + 'lanes' => 'lane', + 'paths' => 'path', + default => $entity, + }; + } + + private function normalizeGateType(string $gateType): string + { + $gateType = strtoupper(trim($gateType)); + return in_array($gateType, [ + selfserve_task_gate_type::ALWAYS->value, + selfserve_task_gate_type::CONDITION->value, + selfserve_task_gate_type::QUESTION->value, + ], true) ? $gateType : selfserve_task_gate_type::ALWAYS->value; + } + + private function conditionBelongsToDepartment(int $conditionId, int $departmentId): bool + { + if ($conditionId <= 0) { + return false; + } + + $statement = db::getPDO()->prepare( + "SELECT COUNT(*) AS c + FROM department_selfserve_conditions + WHERE id = :id + AND department IN (0, :department) + AND deleted_at IS NULL" + ); + $statement->execute([ + ':id' => $conditionId, + ':department' => $departmentId, + ]); + + return (int)($statement->fetch(\PDO::FETCH_ASSOC)['c'] ?? 0) > 0; + } + + private function laneBelongsToDepartment(int $laneId, int $departmentId): bool + { + if ($laneId <= 0) { + return false; + } + + $statement = db::getPDO()->prepare( + "SELECT COUNT(*) AS c + FROM department_lanes + WHERE id = :id + AND department = :department + AND deleted_at IS NULL" + ); + $statement->execute([ + ':id' => $laneId, + ':department' => $departmentId, + ]); + + return (int)($statement->fetch(\PDO::FETCH_ASSOC)['c'] ?? 0) > 0; + } + + /** + * @return array{0:string,1:string} + */ + private function parseNodeId(string $nodeId): array + { + $parts = explode(':', $nodeId, 2); + return [ + strtolower((string)($parts[0] ?? '')), + (string)($parts[1] ?? ''), + ]; + } + + private function jsonArray(mixed $value): string + { + if (is_string($value)) { + $decoded = json_decode($value, true); + $value = is_array($decoded) ? $decoded : array_filter(array_map('trim', explode(',', $value))); + } + if (!is_array($value)) { + $value = []; + } + $json = json_encode(array_values($value), JSON_UNESCAPED_UNICODE); + if ($json === false) { + throw new \RuntimeException('Failed to encode JSON array: ' . json_last_error_msg()); + } + return $json; + } + + private function nullableInt(mixed $value): ?int + { + if ($value === null || $value === '' || $value === 'null') { + return null; + } + $intValue = (int)$value; + return $intValue <= 0 ? null : $intValue; + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_task_attachment_payloads.php b/services/nginx/app/modules/selfserve/classes/selfserve_task_attachment_payloads.php new file mode 100644 index 00000000..4a6b2ea7 --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_task_attachment_payloads.php @@ -0,0 +1,118 @@ +> $tasks + * @return array> + */ + public function attachToTasks(array $tasks): array + { + if ($tasks === []) { + return []; + } + + $taskIds = []; + foreach ($tasks as $task) { + if (!is_array($task)) { + continue; + } + $taskId = $this->taskObjectId($task); + if ($taskId !== null) { + $taskIds[] = $taskId; + } + } + + $attachmentsByTask = []; + if ($taskIds !== []) { + try { + $attachmentsByTask = (new attachments())->listMany(self::OBJECT_TYPE, $taskIds); + } catch (\Throwable) { + $attachmentsByTask = []; + } + } + + $store = new attachment_store(); + return array_values(array_map(function (array $task) use ($attachmentsByTask, $store): array { + $taskId = $this->taskObjectId($task); + $attachments = []; + + if ($taskId !== null && isset($attachmentsByTask[$taskId])) { + foreach ((array)$attachmentsByTask[$taskId] as $attachment) { + if (is_object($attachment)) { + $attachments[] = $this->formatAttachment($attachment, $store); + } + } + } elseif (isset($task['attachments']) && is_array($task['attachments'])) { + $attachments = array_values($task['attachments']); + } + + $task['attachments'] = $attachments; + return $task; + }, $tasks)); + } + + /** + * @param array $task + */ + private function taskObjectId(array $task): ?int + { + $taskId = (int)($task['id'] ?? $task['task_id'] ?? 0); + return $taskId > 0 ? $taskId : null; + } + + /** + * @return array + */ + private function formatAttachment(object $attachment, attachment_store $store): array + { + $content = $this->contentPayload($attachment->content ?? null); + $fileName = $content['document'] ?: $content['image'] ?: null; + + return [ + 'id' => isset($attachment->id) ? (int)$attachment->id : null, + 'object_type' => isset($attachment->object_type) ? (string)$attachment->object_type : self::OBJECT_TYPE, + 'object_id' => isset($attachment->object_id) ? (int)$attachment->object_id : null, + 'content' => $content, + 'download_link' => is_string($fileName) && trim($fileName) !== '' + ? $store->generateDirectDownloadUrl($fileName) + : null, + 'created_at' => isset($attachment->created_at) ? (string)$attachment->created_at : null, + 'updated_at' => isset($attachment->updated_at) ? (string)$attachment->updated_at : null, + ]; + } + + /** + * @return array{image:?string,document:?string,relation:mixed,other:mixed} + */ + private function contentPayload(mixed $content): array + { + $payload = is_object($content) && method_exists($content, 'toArray') + ? $content->toArray() + : (is_array($content) ? $content : (array)$content); + + $relation = $payload['relation'] ?? null; + if (is_object($relation) && method_exists($relation, 'toArray')) { + $relation = $relation->toArray(); + } elseif (is_object($relation)) { + $relation = (array)$relation; + } + + return [ + 'image' => isset($payload['image']) && is_string($payload['image']) ? $payload['image'] : null, + 'document' => isset($payload['document']) && is_string($payload['document']) ? $payload['document'] : null, + 'relation' => $relation, + 'other' => $payload['other'] ?? null, + ]; + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_virtual_hardware.php b/services/nginx/app/modules/selfserve/classes/selfserve_virtual_hardware.php new file mode 100644 index 00000000..038f8371 --- /dev/null +++ b/services/nginx/app/modules/selfserve/classes/selfserve_virtual_hardware.php @@ -0,0 +1,784 @@ + + */ + public function getConfig(int $departmentId): array + { + $pdo = db::getPDO(); + $statement = $pdo->prepare( + 'SELECT config_json + FROM department_selfserve_studio_virtual_hardware + WHERE department_id = :department_id AND deleted_at IS NULL + LIMIT 1' + ); + $statement->execute([':department_id' => $departmentId]); + $row = $statement->fetch(\PDO::FETCH_ASSOC); + if (!is_array($row)) { + return $this->emptyConfig(); + } + + $decoded = json_decode((string)($row['config_json'] ?? '{}'), true); + return $this->normalizeConfig(is_array($decoded) ? $decoded : []); + } + + /** + * @param array $config + * @return array + */ + public function saveConfig(int $departmentId, array $config, ?int $userId = null): array + { + $normalized = $this->normalizeConfig($config); + $json = json_encode($normalized, JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new \RuntimeException('Unable to encode virtual hardware config.'); + } + + $pdo = db::getPDO(); + $statement = $pdo->prepare( + 'INSERT INTO department_selfserve_studio_virtual_hardware + (department_id, config_json, created_by, updated_by, deleted_at) + VALUES + (:department_id, :config_json, :created_by, :updated_by, NULL) + ON DUPLICATE KEY UPDATE + config_json = VALUES(config_json), + updated_by = VALUES(updated_by), + deleted_at = NULL' + ); + $statement->execute([ + ':department_id' => $departmentId, + ':config_json' => $json, + ':created_by' => $userId, + ':updated_by' => $userId, + ]); + + return $normalized; + } + + /** + * @param array $payload + * @param array $realWorkspace + * @return array + */ + public function applyOperation(int $departmentId, string $operation, array $payload, ?int $userId, array $realWorkspace): array + { + $config = $this->getConfig($departmentId); + $operation = strtolower(trim($operation)); + + if ($operation === 'generate_from_lanes') { + $config = $this->generateFromLanes($realWorkspace, $config); + } elseif ($operation === 'upsert_gateway') { + $config = $this->upsertGateway($config, $payload); + } elseif ($operation === 'upsert_binding') { + $config = $this->upsertBinding($config, $payload, $realWorkspace); + } elseif ($operation === 'delete_binding') { + $config = $this->deleteBinding($config, $payload); + } elseif ($operation === 'reset') { + $config = $this->emptyConfig(); + } else { + throw new \RuntimeException('Unsupported virtual hardware operation: ' . $operation); + } + + return $this->saveConfig($departmentId, $config, $userId); + } + + /** + * @param array $workspace + * @return array + */ + public function mergeWorkspace(array $workspace, int $departmentId): array + { + return $this->mergeWorkspaceWithConfig($workspace, $this->getConfig($departmentId)); + } + + /** + * Pure merge helper used by graph serialization and tests. + * + * @param array $workspace + * @param array $config + * @return array + */ + public function mergeWorkspaceWithConfig(array $workspace, array $config): array + { + $config = $this->normalizeConfig($config); + $workspace += [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [], + 'issues' => [], + 'actions' => [], + ]; + + $virtualBindings = (array)($config['bindings'] ?? []); + $enabled = (bool)($config['enabled'] ?? true); + if (!$enabled || $virtualBindings === []) { + $workspace['virtual'] = $this->workspaceVirtualSummary($config, 0, 0); + return $workspace; + } + + $realBindingRelayIds = $this->realBindingRelayIds((array)($workspace['gateways'] ?? [])); + $virtualGateways = $this->virtualGatewaysForWorkspace($config); + $virtualRelays = $this->virtualRelaysForWorkspace($config); + $bindingsByRelayId = $this->indexVirtualBindingsByRelayId($virtualGateways); + + $workspace['gateways'] = array_values(array_merge((array)($workspace['gateways'] ?? []), $virtualGateways)); + $workspace['relays'] = $this->mergeRelays((array)($workspace['relays'] ?? []), $virtualRelays); + $workspace['lanes'] = $this->mergeLaneCoverage((array)($workspace['lanes'] ?? []), $bindingsByRelayId); + + $coveredRelayIds = array_fill_keys(array_keys($bindingsByRelayId), true); + $workspace['issues'] = $this->mergeIssues((array)($workspace['issues'] ?? []), $coveredRelayIds, $virtualGateways); + $workspace['virtual'] = $this->workspaceVirtualSummary($config, count($virtualGateways), count($virtualBindings), $realBindingRelayIds); + $workspace['summary'] = $this->mergeSummary((array)($workspace['summary'] ?? []), $workspace); + + return $workspace; + } + + /** + * @param array $workspace + * @return array + */ + public function validationWarnings(array $workspace): array + { + $virtual = is_array($workspace['virtual'] ?? null) ? (array)$workspace['virtual'] : []; + if (($virtual['has_virtual_hardware'] ?? false) !== true) { + return []; + } + + $warnings = [ + 'Studio uses virtual hardware coverage. Publishing is allowed, but live relay dispatch still requires a real edge gateway and real relay bindings.', + ]; + $virtualOnlyRelays = (array)($virtual['virtual_only_relay_ids'] ?? []); + if ($virtualOnlyRelays !== []) { + $warnings[] = 'Virtual coverage only for relay IDs: ' . implode(', ', $virtualOnlyRelays) . '.'; + } + + return $warnings; + } + + /** + * @param array $workspace + * @param array|null $baseConfig + * @return array + */ + public function generateFromLanes(array $workspace, ?array $baseConfig = null): array + { + $config = $this->normalizeConfig($baseConfig ?? $this->emptyConfig()); + $gatewayKey = self::DEFAULT_GATEWAY_KEY; + $config = $this->upsertGateway($config, [ + 'key' => $gatewayKey, + 'label' => 'Virtual Studio Gateway', + 'status' => 'VIRTUAL', + ]); + + foreach ((array)($workspace['lanes'] ?? []) as $lane) { + if (!is_array($lane)) { + continue; + } + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (!is_array($slot)) { + continue; + } + $relayId = trim((string)($slot['relay_id'] ?? '')); + $role = $this->normalizeRole($slot['slot'] ?? $slot['role'] ?? ''); + if ($relayId === '' || $role === '') { + continue; + } + $config = $this->upsertBinding($config, [ + 'gateway_key' => $gatewayKey, + 'relay_id' => $relayId, + 'role' => $role, + 'services' => [$role], + 'label' => trim((string)($lane['name'] ?? ('Lane ' . ($lane['id'] ?? '')))) . ' ' . $role, + 'lane_id' => (int)($lane['id'] ?? 0), + 'slot' => $role, + 'generated' => true, + ], $workspace); + } + } + + return $config; + } + + /** + * @return array + */ + public function emptyConfig(): array + { + return [ + 'schema_version' => self::SCHEMA_VERSION, + 'enabled' => true, + 'gateways' => [], + 'relays' => [], + 'bindings' => [], + ]; + } + + /** + * @param array $config + * @return array + */ + public function normalizeConfig(array $config): array + { + $normalized = $this->emptyConfig(); + $normalized['schema_version'] = (int)($config['schema_version'] ?? self::SCHEMA_VERSION); + $normalized['enabled'] = filter_var($config['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false; + + foreach ((array)($config['gateways'] ?? []) as $gateway) { + if (!is_array($gateway)) { + continue; + } + $key = $this->normalizeGatewayKey($gateway['key'] ?? $gateway['gateway_key'] ?? $gateway['id'] ?? ''); + if ($key === '') { + continue; + } + $normalized['gateways'][$key] = [ + 'key' => $key, + 'label' => trim((string)($gateway['label'] ?? ('Virtual Gateway ' . $key))), + 'status' => strtoupper(trim((string)($gateway['status'] ?? 'VIRTUAL'))) ?: 'VIRTUAL', + 'metadata' => is_array($gateway['metadata'] ?? null) ? (array)$gateway['metadata'] : [], + ]; + } + + foreach ((array)($config['relays'] ?? []) as $relay) { + if (!is_array($relay)) { + continue; + } + $relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? '')); + if ($relayId === '') { + continue; + } + $normalized['relays'][$relayId] = [ + 'relay_id' => $relayId, + 'name' => trim((string)($relay['name'] ?? $relay['label'] ?? ('Virtual relay ' . $relayId))), + 'status' => strtoupper(trim((string)($relay['status'] ?? 'VIRTUAL'))) ?: 'VIRTUAL', + 'virtual' => true, + ]; + } + + $bindings = []; + foreach ((array)($config['bindings'] ?? []) as $binding) { + if (!is_array($binding)) { + continue; + } + $gatewayKey = $this->normalizeGatewayKey($binding['gateway_key'] ?? $binding['gateway_id'] ?? self::DEFAULT_GATEWAY_KEY); + $relayId = trim((string)($binding['relay_id'] ?? '')); + $role = $this->normalizeRole($binding['role'] ?? $binding['slot'] ?? $binding['service'] ?? ''); + if ($gatewayKey === '' || $relayId === '') { + continue; + } + if (!isset($normalized['gateways'][$gatewayKey])) { + $normalized['gateways'][$gatewayKey] = [ + 'key' => $gatewayKey, + 'label' => 'Virtual Gateway ' . $gatewayKey, + 'status' => 'VIRTUAL', + 'metadata' => [], + ]; + } + if (!isset($normalized['relays'][$relayId])) { + $normalized['relays'][$relayId] = [ + 'relay_id' => $relayId, + 'name' => trim((string)($binding['label'] ?? ('Virtual relay ' . $relayId))), + 'status' => 'VIRTUAL', + 'virtual' => true, + ]; + } + $services = $this->normalizeServiceList($binding['services'] ?? ($role !== '' ? [$role] : [])); + if ($services === [] && $role !== '') { + $services = [$role]; + } + $id = $this->bindingId($gatewayKey, $relayId, $role); + $bindings[$id] = [ + 'id' => $id, + 'gateway_key' => $gatewayKey, + 'relay_id' => $relayId, + 'role' => $role, + 'slot' => $role, + 'services' => $services, + 'label' => trim((string)($binding['label'] ?? ('Virtual ' . ($role ?: 'relay') . ' ' . $relayId))), + 'channel' => array_key_exists('channel', $binding) ? (int)$binding['channel'] : 0, + 'lane_id' => isset($binding['lane_id']) ? (int)$binding['lane_id'] : null, + 'generated' => (bool)($binding['generated'] ?? false), + 'virtual' => true, + ]; + } + + $normalized['gateways'] = array_values($normalized['gateways']); + $normalized['relays'] = array_values($normalized['relays']); + $normalized['bindings'] = array_values($bindings); + + return $normalized; + } + + /** + * @param array $config + * @param array $payload + * @return array + */ + private function upsertGateway(array $config, array $payload): array + { + $config = $this->normalizeConfig($config); + $key = $this->normalizeGatewayKey($payload['key'] ?? $payload['gateway_key'] ?? $payload['id'] ?? self::DEFAULT_GATEWAY_KEY); + if ($key === '') { + throw new \RuntimeException('Virtual gateway key is required.'); + } + + $gateways = []; + foreach ((array)$config['gateways'] as $gateway) { + $gateways[$this->normalizeGatewayKey($gateway['key'] ?? $gateway['id'] ?? '')] = $gateway; + } + $gateways[$key] = [ + 'key' => $key, + 'label' => trim((string)($payload['label'] ?? $gateways[$key]['label'] ?? ('Virtual Gateway ' . $key))), + 'status' => strtoupper(trim((string)($payload['status'] ?? $gateways[$key]['status'] ?? 'VIRTUAL'))) ?: 'VIRTUAL', + 'metadata' => is_array($payload['metadata'] ?? null) ? (array)$payload['metadata'] : (array)($gateways[$key]['metadata'] ?? []), + ]; + $config['gateways'] = array_values($gateways); + + return $this->normalizeConfig($config); + } + + /** + * @param array $config + * @param array $payload + * @param array $workspace + * @return array + */ + private function upsertBinding(array $config, array $payload, array $workspace): array + { + $config = $this->normalizeConfig($config); + $gatewayKey = $this->normalizeGatewayKey($payload['gateway_key'] ?? $payload['gateway_id'] ?? self::DEFAULT_GATEWAY_KEY); + $relayId = trim((string)($payload['relay_id'] ?? '')); + $role = $this->normalizeRole($payload['role'] ?? $payload['slot'] ?? $payload['service'] ?? ''); + if ($gatewayKey === '' || $relayId === '') { + throw new \RuntimeException('Virtual gateway key and relay id are required.'); + } + + $config = $this->upsertGateway($config, ['key' => $gatewayKey]); + $services = $this->normalizeServiceList($payload['services'] ?? ($role !== '' ? [$role] : [])); + if ($services === [] && $role !== '') { + $services = [$role]; + } + $binding = [ + 'id' => $this->bindingId($gatewayKey, $relayId, $role), + 'gateway_key' => $gatewayKey, + 'relay_id' => $relayId, + 'role' => $role, + 'slot' => $role, + 'services' => $services, + 'label' => trim((string)($payload['label'] ?? ('Virtual ' . ($role ?: 'relay') . ' ' . $relayId))), + 'channel' => array_key_exists('channel', $payload) ? (int)$payload['channel'] : 0, + 'lane_id' => isset($payload['lane_id']) ? (int)$payload['lane_id'] : $this->laneIdForRelay($workspace, $relayId), + 'generated' => (bool)($payload['generated'] ?? false), + 'virtual' => true, + ]; + + $bindings = []; + foreach ((array)$config['bindings'] as $existing) { + if (!is_array($existing)) { + continue; + } + $bindings[(string)($existing['id'] ?? $this->bindingId((string)($existing['gateway_key'] ?? ''), (string)($existing['relay_id'] ?? ''), (string)($existing['role'] ?? '')))] = $existing; + } + $bindings[$binding['id']] = $binding; + $config['bindings'] = array_values($bindings); + $config['relays'][] = [ + 'relay_id' => $relayId, + 'name' => trim((string)($payload['relay_label'] ?? $payload['label'] ?? ('Virtual relay ' . $relayId))), + 'status' => 'VIRTUAL', + 'virtual' => true, + ]; + + return $this->normalizeConfig($config); + } + + /** + * @param array $config + * @param array $payload + * @return array + */ + private function deleteBinding(array $config, array $payload): array + { + $config = $this->normalizeConfig($config); + $id = trim((string)($payload['id'] ?? '')); + if ($id === '') { + $id = $this->bindingId( + $this->normalizeGatewayKey($payload['gateway_key'] ?? $payload['gateway_id'] ?? self::DEFAULT_GATEWAY_KEY), + trim((string)($payload['relay_id'] ?? '')), + $this->normalizeRole($payload['role'] ?? $payload['slot'] ?? '') + ); + } + + $config['bindings'] = array_values(array_filter((array)$config['bindings'], static function (array $binding) use ($id): bool { + return (string)($binding['id'] ?? '') !== $id; + })); + + return $this->normalizeConfig($config); + } + + /** + * @param array $config + * @return array> + */ + private function virtualGatewaysForWorkspace(array $config): array + { + $bindingsByGateway = []; + foreach ((array)($config['bindings'] ?? []) as $binding) { + if (!is_array($binding)) { + continue; + } + $gatewayKey = $this->normalizeGatewayKey($binding['gateway_key'] ?? self::DEFAULT_GATEWAY_KEY); + $bindingsByGateway[$gatewayKey][] = [ + ...$binding, + 'gateway_id' => $gatewayKey, + 'gateway_key' => $gatewayKey, + 'node_id' => 'binding:' . $gatewayKey . ':' . (string)($binding['relay_id'] ?? '') . ':' . count($bindingsByGateway[$gatewayKey] ?? []), + 'virtual' => true, + ]; + } + + $gateways = []; + foreach ((array)($config['gateways'] ?? []) as $gateway) { + if (!is_array($gateway)) { + continue; + } + $key = $this->normalizeGatewayKey($gateway['key'] ?? self::DEFAULT_GATEWAY_KEY); + $bindings = array_values($bindingsByGateway[$key] ?? []); + if ($bindings === []) { + continue; + } + $gateways[] = [ + 'id' => $key, + 'key' => $key, + 'label' => (string)($gateway['label'] ?? ('Virtual Gateway ' . $key)), + 'status' => (string)($gateway['status'] ?? 'VIRTUAL'), + 'virtual' => true, + 'studio_only' => true, + 'bindings' => $bindings, + 'metadata' => (array)($gateway['metadata'] ?? []), + ]; + } + + return $gateways; + } + + /** + * @param array $config + * @return array> + */ + private function virtualRelaysForWorkspace(array $config): array + { + return array_values(array_map(static function (array $relay): array { + return [ + ...$relay, + 'virtual' => true, + 'studio_only' => true, + ]; + }, (array)($config['relays'] ?? []))); + } + + /** + * @param array> $gateways + * @return array>> + */ + private function indexVirtualBindingsByRelayId(array $gateways): array + { + $bindings = []; + foreach ($gateways as $gateway) { + foreach ((array)($gateway['bindings'] ?? []) as $binding) { + if (!is_array($binding)) { + continue; + } + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + $bindings[$relayId][] = [ + ...$binding, + 'gateway_id' => (string)($gateway['id'] ?? ''), + 'gateway_label' => (string)($gateway['label'] ?? 'Virtual Gateway'), + 'gateway_status' => (string)($gateway['status'] ?? 'VIRTUAL'), + 'virtual' => true, + 'studio_only' => true, + ]; + } + } + return $bindings; + } + + /** + * @param array> $realRelays + * @param array> $virtualRelays + * @return array> + */ + private function mergeRelays(array $realRelays, array $virtualRelays): array + { + $rows = []; + foreach (array_merge($realRelays, $virtualRelays) as $relay) { + if (!is_array($relay)) { + continue; + } + $relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? '')); + if ($relayId === '') { + continue; + } + $rows[$relayId . ':' . (!empty($relay['virtual']) ? 'virtual' : 'real')] = $relay; + } + return array_values($rows); + } + + /** + * @param array> $lanes + * @param array>> $bindingsByRelayId + * @return array> + */ + private function mergeLaneCoverage(array $lanes, array $bindingsByRelayId): array + { + foreach ($lanes as $laneIndex => $lane) { + if (!is_array($lane)) { + continue; + } + $required = 0; + $bound = 0; + foreach ((array)($lane['relay_slots'] ?? []) as $slotIndex => $slot) { + if (!is_array($slot)) { + continue; + } + $required += 1; + $relayId = trim((string)($slot['relay_id'] ?? '')); + $covered = (bool)($slot['coverage']['covered'] ?? false); + if (!$covered && $relayId !== '' && isset($bindingsByRelayId[$relayId])) { + $slot['coverage'] = [ + 'relay_id' => $relayId, + 'covered' => true, + 'status' => 'VIRTUAL', + 'binding_count' => count($bindingsByRelayId[$relayId]), + 'primary_binding' => $bindingsByRelayId[$relayId][0] ?? null, + 'bindings' => $bindingsByRelayId[$relayId], + 'virtual' => true, + 'studio_only' => true, + ]; + $slot['virtual'] = true; + $covered = true; + } + if ($covered) { + $bound += 1; + } + $lane['relay_slots'][$slotIndex] = $slot; + } + $lane['binding_coverage'] = [ + 'required' => $required, + 'bound' => $bound, + 'missing' => max(0, $required - $bound), + 'state' => $required === 0 ? 'NOT_REQUIRED' : ($bound === $required ? 'READY' : 'MISSING'), + 'virtual' => $this->laneHasVirtualCoverage($lane), + ]; + $lanes[$laneIndex] = $lane; + } + + return $lanes; + } + + /** + * @param array $lane + */ + private function laneHasVirtualCoverage(array $lane): bool + { + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (is_array($slot) && !empty($slot['coverage']['virtual'])) { + return true; + } + } + return false; + } + + /** + * @param array> $issues + * @param array $coveredRelayIds + * @param array> $virtualGateways + * @return array> + */ + private function mergeIssues(array $issues, array $coveredRelayIds, array $virtualGateways): array + { + $filtered = []; + foreach ($issues as $issue) { + if (!is_array($issue)) { + continue; + } + $code = strtoupper((string)($issue['code'] ?? '')); + if (in_array($code, ['LANE_BINDING_GAP', 'SCANNER_LANE_PARTIAL', 'SELFSERVE_PARTIAL_READY'], true)) { + $filtered[] = [ + ...$issue, + 'severity' => 'info', + 'virtual' => true, + 'message' => (string)($issue['message'] ?? 'Relay coverage is incomplete.') . ' Studio virtual hardware covers this for dry runs only.', + ]; + continue; + } + if (in_array($code, ['NO_GATEWAY', 'NO_ONLINE_GATEWAY'], true) && $virtualGateways !== []) { + $filtered[] = [ + ...$issue, + 'severity' => 'warning', + 'virtual' => true, + 'message' => (string)($issue['message'] ?? 'Real gateway is not ready.') . ' A virtual studio gateway is available for dry runs only.', + ]; + continue; + } + $filtered[] = $issue; + } + + $filtered[] = [ + 'severity' => 'warning', + 'code' => 'VIRTUAL_HARDWARE_ACTIVE', + 'message' => 'Virtual studio hardware is active. It counts for studio validation and simulation only; live dispatch still requires real gateway bindings.', + 'virtual' => true, + 'relay_ids' => array_keys($coveredRelayIds), + ]; + + return $filtered; + } + + /** + * @param array $summary + * @param array $workspace + * @return array + */ + private function mergeSummary(array $summary, array $workspace): array + { + $virtual = (array)($workspace['virtual'] ?? []); + $summary['virtual_gateway_count'] = (int)($virtual['gateway_count'] ?? 0); + $summary['virtual_binding_count'] = (int)($virtual['binding_count'] ?? 0); + $summary['has_virtual_hardware'] = (bool)($virtual['has_virtual_hardware'] ?? false); + return $summary; + } + + /** + * @param array $config + * @return array + */ + /** + * @param array $realBindingRelayIds + */ + private function workspaceVirtualSummary(array $config, int $gatewayCount, int $bindingCount, array $realBindingRelayIds = []): array + { + $relayIds = []; + foreach ((array)($config['bindings'] ?? []) as $binding) { + if (is_array($binding) && trim((string)($binding['relay_id'] ?? '')) !== '') { + $relayId = trim((string)$binding['relay_id']); + if (!isset($realBindingRelayIds[$relayId])) { + $relayIds[] = $relayId; + } + } + } + + return [ + 'schema_version' => (int)($config['schema_version'] ?? self::SCHEMA_VERSION), + 'enabled' => (bool)($config['enabled'] ?? true), + 'has_virtual_hardware' => $bindingCount > 0, + 'gateway_count' => $gatewayCount, + 'binding_count' => $bindingCount, + 'virtual_only_relay_ids' => array_values(array_unique($relayIds)), + 'config' => $config, + ]; + } + + /** + * @param array> $gateways + * @return array + */ + private function realBindingRelayIds(array $gateways): array + { + $relayIds = []; + foreach ($gateways as $gateway) { + if (!is_array($gateway) || !empty($gateway['virtual'])) { + continue; + } + foreach ((array)($gateway['bindings'] ?? []) as $binding) { + if (!is_array($binding)) { + continue; + } + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId !== '') { + $relayIds[$relayId] = true; + } + } + } + return $relayIds; + } + + /** + * @param array $workspace + */ + private function laneIdForRelay(array $workspace, string $relayId): ?int + { + foreach ((array)($workspace['lanes'] ?? []) as $lane) { + if (!is_array($lane)) { + continue; + } + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (is_array($slot) && trim((string)($slot['relay_id'] ?? '')) === $relayId) { + return (int)($lane['id'] ?? 0) ?: null; + } + } + } + return null; + } + + private function normalizeGatewayKey(mixed $value): string + { + return trim((string)$value); + } + + private function normalizeRole(mixed $value): string + { + return strtoupper(trim((string)$value)); + } + + /** + * @return array + */ + private function normalizeServiceList(mixed $value): array + { + if (is_string($value)) { + $decoded = json_decode($value, true); + $value = json_last_error() === JSON_ERROR_NONE && is_array($decoded) ? $decoded : explode(',', $value); + } + if (!is_array($value)) { + $value = [$value]; + } + + $services = []; + foreach ($value as $entry) { + if (is_array($entry)) { + continue; + } + $service = $this->normalizeRole($entry); + if ($service !== '') { + $services[$service] = true; + } + } + return array_keys($services); + } + + private function bindingId(string $gatewayKey, string $relayId, string $role): string + { + return $gatewayKey . ':' . $relayId . ':' . ($role !== '' ? $role : 'relay'); + } +} diff --git a/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php b/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php index 3ca2f3b0..cf90eca1 100644 --- a/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php +++ b/services/nginx/app/modules/selfserve/classes/selfserve_wash_flow.php @@ -5,7 +5,12 @@ namespace modules\selfserve\classes; require_once WD . '/classes/selfserve.php'; require_once WD . '/classes/selfserve_schema_bootstrap.php'; require_once WD . '/modules/selfserve/classes/selfserve_condition_evaluator.php'; +require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php'; require_once WD . '/modules/selfserve/classes/selfserve_config_versioning.php'; +require_once WD . '/modules/selfserve/classes/selfserve_studio_action_runner.php'; +require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php'; +require_once WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php'; +require_once WD . '/modules/selfserve/helpers/selfserve_lane_command.php'; require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php'; require_once WD . '/modules/selfserve/helpers/selfserve_lane_services.php'; require_once WD . '/modules/selfserve/helpers/selfserve_lane_state.php'; @@ -16,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'; @@ -30,7 +37,11 @@ require_once WD . '/objects/selfserve_wash_sessions_o.php'; use classes\selfserve; use classes\selfserve_schema_bootstrap; +use modules\selfserve\classes\selfserve_lane_command_arguments; +use modules\selfserve\helpers\selfserve_lane_command; use modules\selfserve\classes\selfserve_config_versioning; +use modules\selfserve\classes\selfserve_studio_action_runner; +use modules\selfserve\classes\selfserve_studio_actions; use modules\selfserve\helpers\selfserve_task_gate_type; use modules\selfserve\helpers\selfserve_lane_relay; use modules\selfserve\helpers\selfserve_lane_services; @@ -62,18 +73,43 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $this->conditionEvaluator ??= new selfserve_condition_evaluator(); } - public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null): array + public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null, array $options = []): array { - $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride); + $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options); $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']); return $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null); } - public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null): array + /** + * @param array $options + * @return array + */ + public function previewStudioSimulation(int $departmentId, int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null, array $options = []): array { - $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride); + $options['debug'] = true; + $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options); $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']); + $response = $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null); + $response['simulator_version'] = 2; + $response['dry_run'] = true; + $response['mode'] = 'full_dry_run'; + $response['config_source'] = (string)($options['config_source'] ?? 'draft'); + $response['debug'] = $this->buildStudioDebugPayload($departmentId, $snapshot, $options); + + return $response; + } + + public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array + { + $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options); + $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']); + + if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) { + return $session->exists() + ? $this->getSessionSummary((int)$session->id) + : $this->formatBlockedSessionSummary($snapshot); + } if (!$session->exists()) { $session = (new selfserve_wash_sessions_o())->add( @@ -108,7 +144,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']), ]); - $this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine); + if ($syncRelayState) { + $this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine); + } return $this->getSessionSummary((int)$session->id); } @@ -124,7 +162,10 @@ class selfserve_wash_flow implements selfserve_wash_flow_i if ($normalizedReg === null) { throw new \RuntimeException('No active self-serve wash session found for the lane.'); } - $summary = $this->synchronizeSession($laneId, $normalizedReg, null, false); + $summary = $this->synchronizeSession($laneId, $normalizedReg, null, false, null, false); + if (empty($summary['session']['id'])) { + throw new \RuntimeException((string)($summary['blocked_reason'] ?? 'Self-serve is disabled for this lane.')); + } $session = (new selfserve_wash_sessions_o())->select((int)$summary['session']['id']); } @@ -148,7 +189,6 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $lane->setWashStartTime(time()); } $washStartedAt = (int)$lane->getWashStartTime(); - $this->enableCleanerRelayForStartedWash($lane); $session->markMachineStartTriggered( $washStartedAt > 0 ? date('Y-m-d H:i:s', $washStartedAt) : null @@ -158,10 +198,57 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'reg' => $effectiveReg, 'customer_number' => $customerNumber, ]); + $actionContext = [ + 'lane_id' => $laneId, + 'reg' => $effectiveReg, + 'customer_number' => $customerNumber, + 'session_id' => (int)$session->id, + 'source_payload' => $payload, + ]; + try { + if ($effectiveReg !== '') { + $actionSnapshot = $this->buildEligibilitySnapshot( + $laneId, + $effectiveReg, + $customerNumber, + $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(), + ['config_source' => 'published'] + ); + if (is_array($actionSnapshot['evaluation_trace']['condition_results'] ?? null)) { + $actionContext['condition_results'] = (array)$actionSnapshot['evaluation_trace']['condition_results']; + } + if (is_array($actionSnapshot['evaluation_trace']['visibility_condition_results'] ?? null)) { + $actionContext['visibility_condition_results'] = (array)$actionSnapshot['evaluation_trace']['visibility_condition_results']; + } + $actionContext['allowed_services'] = (array)($actionSnapshot['allowed_services'] ?? []); + $actionContext['vehicle_type_id'] = $actionSnapshot['vehicle_type_id'] ?? null; + $actionContext['product'] = $actionSnapshot['vehicle_type_id'] ?? null; + $actionContext['machine_type_id'] = $actionSnapshot['machine_type']['id'] ?? null; + } + } catch (\Throwable) { + // Action execution should stay best-effort even when preview context cannot be rebuilt. + } + (new selfserve_studio_action_runner())->executeForLaneEvent( + $lane, + selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED, + selfserve_studio_actions::MODE_MACHINE, + $actionContext + ); + $this->enableCleanerRelayForStartedWash($lane); return $this->getSessionSummary((int)$session->id); } + public function hasMachineStartTriggeredForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null): bool + { + $normalizedReg = $reg === null || trim($reg) === '' ? null : selfserve::standardize_registration($reg); + $session = $normalizedReg !== null + ? $this->findLatestOpenSession($laneId, $normalizedReg, $customerNumber) + : $this->findLatestOpenSessionByLane($laneId, $customerNumber); + + return $session->exists() && (bool)$session->machine_start_triggered->value(); + } + protected function enableCleanerRelayForStartedWash(selfserve_lane $lane): void { try { @@ -186,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) { @@ -248,6 +326,24 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $answerRows = (new selfserve_wash_session_answers_o())->listBySession($sessionId); $answers = $this->buildSessionQuestions($session, $answerRows); + $metadata = is_array($session->metadata_json->value()) ? $session->metadata_json->value() : []; + $allowedServices = $this->normalizeServiceNames( + is_array($metadata['allowed_services'] ?? null) ? (array)$metadata['allowed_services'] : [] + ); + $machineAvailable = array_key_exists('machine_available', $metadata) + ? (bool)$metadata['machine_available'] + : ($lane->exists() && !empty($lane->relay_machine_id->value())); + $allVisibleQuestionsAnswered = array_key_exists('all_visible_questions_answered', $metadata) + ? (bool)$metadata['all_visible_questions_answered'] + : true; + if (!array_key_exists('all_visible_questions_answered', $metadata)) { + foreach ($answers as $answer) { + if (($answer['answer'] ?? null) === null) { + $allVisibleQuestionsAnswered = false; + break; + } + } + } $tasks = array_map(function (array $row): array { return [ @@ -259,6 +355,10 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'dynamic_images_vehicle_type' => $row['dynamic_images_vehicle_type'] === null ? null : (int)$row['dynamic_images_vehicle_type'] ]; }, (new selfserve_wash_session_tasks_o())->listBySession($sessionId)); + if (array_key_exists('allowed_services', $metadata) || (bool)$session->allowed->value() === false) { + $tasks = $this->filterTasksForAllowedServices($tasks, $allowedServices); + } + $tasks = (new selfserve_task_attachment_payloads())->attachToTasks($tasks); $events = array_map(function (array $row): array { return [ @@ -276,8 +376,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'questions' => $answers, 'tasks' => $tasks, 'events' => $events, - 'config_version_id' => is_array($session->metadata_json->value()) ? ($session->metadata_json->value()['config_version_id'] ?? null) : null, - 'evaluation_trace' => is_array($session->metadata_json->value()) ? ($session->metadata_json->value()['evaluation_trace'] ?? null) : null, + 'allowed_services' => $allowedServices, + 'machine_available' => $machineAvailable, + 'all_visible_questions_answered' => $allVisibleQuestionsAnswered, + 'allowed' => (bool)$session->allowed->value(), + 'config_version_id' => $metadata['config_version_id'] ?? null, + 'evaluation_trace' => $metadata['evaluation_trace'] ?? null, ]; } @@ -313,7 +417,60 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return $this->getSessionSummary((int)$session->id); } - protected function buildEligibilitySnapshot(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null): array + public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array + { + $lane = (new selfserve())->lane($laneId); + $session = $this->resolveForceStopSession($laneId, $sessionId); + $runtimeSnapshot = $this->buildForceStopRuntimeSnapshot($lane); + $hasRuntime = $this->laneRuntimeLooksActive($runtimeSnapshot); + if (!$session->exists() && !$hasRuntime) { + throw new \RuntimeException('No active self-serve wash session or lane runtime found.'); + } + + $orderId = null; + if ($bill) { + try { + if ($lane->invoice() !== true) { + throw new \RuntimeException('Elapsed-minute invoice was not created.'); + } + $orderId = method_exists($lane, 'getLastInvoiceOrderId') ? $lane->getLastInvoiceOrderId() : null; + } catch (\Throwable $e) { + throw new \RuntimeException('Failed to bill elapsed minutes before force stop: ' . $e->getMessage(), 409, $e); + } + } + + $summary = null; + if ($session->exists()) { + $eventPayload = [ + 'lane_id' => $laneId, + 'reason' => $reason, + 'user_id' => $userId, + 'bill' => $bill, + 'order_id' => $orderId, + 'runtime_before_reset' => $runtimeSnapshot, + 'forced_at' => date('Y-m-d H:i:s'), + ]; + $session->markForceStopped($orderId, $eventPayload); + $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_FORCE_STOPPED, $eventPayload); + $summary = $this->getSessionSummary((int)$session->id); + } + + $lane->execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments()); + + return [ + 'lane_id' => $laneId, + 'forced' => true, + 'bill' => $bill, + 'order_id' => $orderId, + 'session' => $summary, + 'runtime_before_reset' => $runtimeSnapshot, + ]; + } + + /** + * @param array $options + */ + protected function buildEligibilitySnapshot(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null, array $options = []): array { $normalizedReg = selfserve::standardize_registration($reg); $lane = (new department_lanes_o())->select($laneId); @@ -323,19 +480,88 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $departmentId = (int)$lane->department->value(); $machineTypeId = $lane->machine_type_id->value() === null ? null : (int)$lane->machine_type_id->value(); - $publishedConfig = (new selfserve_config_versioning())->getPublishedConfig($departmentId); - $publishedConfigVersionId = $publishedConfig['version_id'] ?? null; - $publishedConfigPayload = is_array($publishedConfig['config'] ?? null) ? $publishedConfig['config'] : null; + if (!$lane->isSelfServeEnabled()) { + $vehicle = $this->findVehicleByRegistration($normalizedReg); + $vehicleData = $vehicle?->asArray(); + $vehicleTypeId = $this->resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride); + $resolvedCustomerNumber = $customerNumber ?? ($vehicle !== null ? (int)$vehicle->customer_id->value() : null); + + return [ + 'lane' => $lane->asArray(), + 'machine_type' => null, + 'vehicle' => $vehicleData, + 'reg' => $normalizedReg, + 'customer_number' => $resolvedCustomerNumber, + 'vehicle_type_id' => $vehicleTypeId, + 'answers' => [], + 'persisted_answers' => [], + 'persisted_answer_customer_number' => null, + 'answer_overrides' => [], + 'answer_sources' => [], + 'questions' => [], + 'conditions' => [], + 'tasks' => [], + 'allowed_services' => [], + 'machine_available' => false, + 'all_visible_questions_answered' => false, + 'allowed' => false, + 'blocked_reason' => 'Self-serve is disabled for this lane.', + 'config_version_id' => null, + 'config_source' => (string)($options['config_source'] ?? 'published'), + 'evaluation_trace' => [ + 'blocked' => true, + 'blocking_reasons' => ['LANE_SELFSERVE_DISABLED'], + 'disabled_lane' => true, + 'message' => 'Self-serve is disabled for this lane.', + 'visibility_condition_results' => [], + 'condition_results' => [], + 'visibility_expression_traces' => [], + 'condition_expression_traces' => [], + 'task_gates' => [], + 'visible_question_ids' => [], + ], + 'debug_candidates' => [ + 'questions' => [], + 'conditions' => [], + 'rules' => [], + 'tasks' => [], + 'actions' => [], + 'visible_answers' => [], + ], + ]; + } + $configSource = (string)($options['config_source'] ?? 'published'); + $publishedConfigVersionId = $options['config_version_id'] ?? null; + $publishedConfigPayload = is_array($options['config_payload'] ?? null) ? (array)$options['config_payload'] : null; + $versioning = new selfserve_config_versioning(); + if ($publishedConfigPayload === null) { + $publishedConfig = $versioning->getPublishedV2Config($departmentId); + $publishedConfigVersionId = $publishedConfig['version_id'] ?? null; + $publishedConfigPayload = is_array($publishedConfig['config'] ?? null) ? $publishedConfig['config'] : null; + $configSource = $publishedConfigPayload === null ? 'legacy' : 'published'; + } + $isV2Config = is_array($publishedConfigPayload) && $versioning->isV2Config($publishedConfigPayload); $vehicle = $this->findVehicleByRegistration($normalizedReg); $vehicleData = $vehicle?->asArray(); $vehicleTypeId = $this->resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride); $resolvedCustomerNumber = $customerNumber ?? ($vehicle !== null ? (int)$vehicle->customer_id->value() : null); + $persistedAnswerCustomerNumber = $this->resolvePersistedAnswerCustomerNumber($resolvedCustomerNumber); $questions = $this->loadQuestions($departmentId, $laneId, $vehicleTypeId, $publishedConfigPayload); $conditions = $this->loadConditions($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload); $rules = $this->loadConditionRules($conditions, $publishedConfigPayload); - $answers = (new department_selfserve_vehicle_conditions_o())->getAnswerMapForVehicle($departmentId, $laneId, $normalizedReg); - $visibilityConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $answers); + $persistedAnswers = $this->loadPersistedAnswers($departmentId, $laneId, $normalizedReg, $persistedAnswerCustomerNumber); + $answerOverrides = $this->normalizeAnswerOverrides($options['answer_overrides'] ?? []); + $answers = $this->applyAnswerOverrides($persistedAnswers, $answerOverrides); + $answerSources = $this->buildAnswerSources($persistedAnswers, $answerOverrides); + if ($isV2Config) { + $visibilityEvaluation = $this->conditionEvaluator->evaluateExpressionsWithTrace($conditions, $answers); + $visibilityConditionResults = (array)($visibilityEvaluation['results'] ?? []); + $visibilityExpressionTrace = (array)($visibilityEvaluation['trace'] ?? []); + } else { + $visibilityConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $answers); + $visibilityExpressionTrace = []; + } $visibleQuestions = []; $visibleQuestionIds = []; @@ -352,35 +578,33 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'description' => (string)($question['description'] ?? ''), 'condition_id' => $gateId, 'order_priority' => (int)($question['order_priority'] ?? 0), - 'answer' => array_key_exists($questionId, $answers) ? (bool)$answers[$questionId] : null, + 'answer' => array_key_exists($questionId, $answers) ? $answers[$questionId] : null, + 'answer_source' => $answerSources[$questionId] ?? 'missing', ]; } usort($visibleQuestions, static fn(array $a, array $b): int => $a['order_priority'] <=> $b['order_priority']); $visibleAnswers = $this->filterAnswersToVisibleQuestions($answers, $visibleQuestionIds); - $serviceConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $visibleAnswers); + if ($isV2Config) { + $serviceEvaluation = $this->conditionEvaluator->evaluateExpressionsWithTrace($conditions, $visibleAnswers); + $serviceConditionResults = (array)($serviceEvaluation['results'] ?? []); + $serviceExpressionTrace = (array)($serviceEvaluation['trace'] ?? []); + } else { + $serviceConditionResults = $this->conditionEvaluator->evaluate($conditions, $rules, $visibleAnswers); + $serviceExpressionTrace = []; + } - $tasks = $this->loadTasks($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload); + $tasks = (new selfserve_task_attachment_payloads())->attachToTasks( + $this->loadTasks($departmentId, $laneId, $vehicleTypeId, $machineTypeId, $publishedConfigPayload) + ); $activeTasks = []; $taskGateTrace = []; $conditionIds = array_map(static fn(array $condition): int => (int)($condition['id'] ?? 0), $conditions); foreach ($tasks as $task) { $gateId = $this->nullableInt($task['condition_id'] ?? null); - $typedGateType = selfserve_task_gate_type::tryFrom((string)($task['gate_type'] ?? '')); - $typedGateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); - - if ($typedGateType === null) { - if ($gateId === null) { - $typedGateType = selfserve_task_gate_type::ALWAYS; - $typedGateRefId = null; - } elseif (in_array($gateId, $conditionIds, true)) { - $typedGateType = selfserve_task_gate_type::CONDITION; - $typedGateRefId = $gateId; - } else { - $typedGateType = selfserve_task_gate_type::QUESTION; - $typedGateRefId = $gateId; - } - } + $resolvedGate = $this->resolveTaskGate($task, $conditionIds); + $typedGateType = $resolvedGate['gate_type']; + $typedGateRefId = $resolvedGate['gate_ref_id']; $gateSatisfied = $this->conditionEvaluator->taskGateSatisfiedTyped( $typedGateType->value, @@ -410,8 +634,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'gate_ref_id' => $typedGateRefId, 'order_priority' => (int)($task['order_priority'] ?? 0), 'services' => $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), - 'buttons' => $this->normalizeIntArray($this->normalizeJsonArray($task['buttons'] ?? null)), - 'dynamic_images_vehicle_type' => (int)($task['dynamic_images_vehicle_type'] ?? 0), + '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' => $task['attachments'] ?? [], ]; } usort($activeTasks, static fn(array $a, array $b): int => $a['order_priority'] <=> $b['order_priority']); @@ -437,6 +662,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i $machineAllowed = $allVisibleQuestionsAnswered && $machineAvailable && in_array(selfserve_lane_services::MACHINE->name, $allowedServices, true); + $visibleTasks = $this->filterTasksForAllowedServices($activeTasks, $allowedServices); $machineType = null; if ($machineTypeId !== null) { @@ -454,32 +680,48 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'customer_number' => $resolvedCustomerNumber, 'vehicle_type_id' => $vehicleTypeId, 'answers' => $answers, + 'persisted_answers' => $persistedAnswers, + 'persisted_answer_customer_number' => $persistedAnswerCustomerNumber, + 'answer_overrides' => $answerOverrides, + 'answer_sources' => $answerSources, 'questions' => $visibleQuestions, 'conditions' => $serviceConditionResults, - 'tasks' => $activeTasks, + 'tasks' => $visibleTasks, 'allowed_services' => $allowedServices, 'machine_available' => $machineAvailable, 'all_visible_questions_answered' => $allVisibleQuestionsAnswered, 'allowed' => $machineAllowed, 'config_version_id' => $publishedConfigVersionId === null ? null : (int)$publishedConfigVersionId, + 'config_source' => $configSource, 'evaluation_trace' => [ + 'visibility_condition_results' => $visibilityConditionResults, 'condition_results' => $serviceConditionResults, + 'visibility_expression_traces' => $visibilityExpressionTrace, + 'condition_expression_traces' => $serviceExpressionTrace, 'task_gates' => $taskGateTrace, 'visible_question_ids' => $visibleQuestionIds, ], + 'debug_candidates' => [ + 'questions' => $questions, + 'conditions' => $conditions, + 'rules' => $rules, + 'tasks' => $tasks, + 'actions' => $isV2Config ? array_values((array)($publishedConfigPayload['actions'] ?? [])) : [], + 'visible_answers' => $visibleAnswers, + ], ]; } 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, @@ -549,12 +791,2152 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'machine_available' => $snapshot['machine_available'], 'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'], 'allowed' => $snapshot['allowed'], + 'blocked_reason' => $snapshot['blocked_reason'] ?? null, 'session' => $session, 'config_version_id' => $snapshot['config_version_id'] ?? null, + 'config_source' => $snapshot['config_source'] ?? null, + 'evaluation_trace' => $snapshot['evaluation_trace'] ?? null, + ]; + } + + protected function formatBlockedSessionSummary(array $snapshot): array + { + return [ + 'session' => null, + 'lane' => $snapshot['lane'], + 'machine_type' => $snapshot['machine_type'], + 'questions' => [], + 'tasks' => [], + 'events' => [], + 'allowed' => false, + 'allowed_services' => [], + 'machine_available' => false, + 'blocked_reason' => $snapshot['blocked_reason'] ?? 'Self-serve is disabled for this lane.', + 'config_version_id' => $snapshot['config_version_id'] ?? null, 'evaluation_trace' => $snapshot['evaluation_trace'] ?? null, ]; } + /** + * @param array $snapshot + * @param array $options + * @return array + */ + public function buildStudioDebugPayload(int $departmentId, array $snapshot, array $options = []): array + { + $lookups = is_array($options['lookups'] ?? null) ? (array)$options['lookups'] : []; + $candidates = is_array($snapshot['debug_candidates'] ?? null) ? (array)$snapshot['debug_candidates'] : []; + $questions = $this->buildDebugQuestions($snapshot, (array)($candidates['questions'] ?? []), $lookups); + $rules = $this->buildDebugRules($snapshot, (array)($candidates['rules'] ?? []), $lookups); + $conditions = $this->buildDebugConditions($snapshot, (array)($candidates['conditions'] ?? []), $rules, $lookups); + $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); + $annotations = $this->buildGraphAnnotations($snapshot, $questions, $conditions, $rules, $tasks, $actions, $hardware, (array)($options['graph'] ?? [])); + + return [ + 'summary' => $summary, + 'parameters' => [ + 'department_id' => $departmentId, + 'department' => $this->debugLabel($lookups, 'departments', $departmentId, 'Department ' . $departmentId), + 'lane_id' => (int)($snapshot['lane']['id'] ?? 0), + 'lane' => $this->debugLabel($lookups, 'lanes', $snapshot['lane']['id'] ?? null, (string)($snapshot['lane']['name'] ?? 'Lane')), + 'machine_type_id' => $snapshot['machine_type']['id'] ?? null, + 'machine_type' => $this->debugLabel($lookups, 'machine_types', $snapshot['machine_type']['id'] ?? null, 'No machine type'), + 'vehicle_type_id' => $snapshot['vehicle_type_id'], + 'vehicle_type' => $this->debugLabel($lookups, 'vehicle_types', $snapshot['vehicle_type_id'] ?? null, 'Auto'), + 'registration' => $snapshot['reg'], + 'customer_number' => $snapshot['customer_number'], + 'config_source' => $snapshot['config_source'] ?? ($options['config_source'] ?? 'draft'), + 'config_version_id' => $snapshot['config_version_id'] ?? null, + 'hardware_mode' => $options['hardware_mode'] ?? 'studio', + 'mode' => 'full_dry_run', + 'dry_run' => true, + ], + 'stages' => $stages, + 'questions' => $questions, + 'conditions' => $conditions, + 'rules' => $rules, + 'tasks' => $tasks, + 'actions' => $actions, + 'dynamic_image_buttons' => $dynamicImageButtons, + 'decisions' => $decisions, + 'hardware' => $hardware, + 'signal_timeline' => (array)($hardware['signal_timeline'] ?? []), + 'graph_annotations' => $annotations, + 'recommendations' => $recommendations, + ]; + } + + /** + * @param mixed $raw + * @return array + */ + protected function normalizeAnswerOverrides(mixed $raw): array + { + $overrides = []; + if (!is_array($raw)) { + return $overrides; + } + + foreach ($raw as $key => $entry) { + if (is_array($entry)) { + $questionId = (int)($entry['question_id'] ?? $entry['id'] ?? $key); + $value = $entry['value'] ?? $entry['answer'] ?? null; + } else { + $questionId = (int)$key; + $value = $entry; + } + if ($questionId <= 0) { + continue; + } + if ($value === null || $value === '' || strtolower((string)$value) === 'unset' || strtolower((string)$value) === 'null') { + $overrides[$questionId] = null; + continue; + } + $parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + $overrides[$questionId] = $parsed; + } + + return $overrides; + } + + /** + * @param array $answers + * @param array $overrides + * @return array + */ + protected function applyAnswerOverrides(array $answers, array $overrides): array + { + foreach ($overrides as $questionId => $value) { + $answers[(int)$questionId] = $value; + } + return $answers; + } + + /** + * @param array $answers + * @param array $overrides + * @return array + */ + protected function buildAnswerSources(array $answers, array $overrides): array + { + $sources = []; + foreach ($answers as $questionId => $_value) { + $sources[(int)$questionId] = 'saved'; + } + foreach ($overrides as $questionId => $_value) { + $sources[(int)$questionId] = 'override'; + } + return $sources; + } + + protected function resolvePersistedAnswerCustomerNumber(?int $resolvedCustomerNumber): ?int + { + if ($resolvedCustomerNumber === null || $resolvedCustomerNumber <= 0) { + return null; + } + + return $resolvedCustomerNumber; + } + + /** + * @return array + */ + protected function loadPersistedAnswers(int $departmentId, int $laneId, string $reg, ?int $customerNumber): array + { + if ($customerNumber === null || $customerNumber <= 0) { + return []; + } + + return (new department_selfserve_vehicle_conditions_o())->getAnswerMapForVehicle( + $departmentId, + $laneId, + $reg, + $customerNumber + ); + } + + /** + * @param array $snapshot + * @param array> $questions + * @param array $lookups + * @return array> + */ + protected function buildDebugQuestions(array $snapshot, array $questions, array $lookups): array + { + $visibleIds = array_flip(array_map('intval', (array)($snapshot['evaluation_trace']['visible_question_ids'] ?? []))); + $answers = is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : []; + $sources = is_array($snapshot['answer_sources'] ?? null) ? (array)$snapshot['answer_sources'] : []; + $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) { + $questionId = (int)($question['id'] ?? 0); + if ($questionId <= 0) { + continue; + } + $gateId = $this->nullableInt($question['condition_id'] ?? null); + $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, + 'node_ids' => ['question:' . $questionId], + 'label' => $label, + 'visible' => $visible, + 'state' => $state, + 'answer' => $answer, + 'answer_source' => $sources[$questionId] ?? 'missing', + 'condition_id' => $gateId, + 'condition' => $conditionLabel, + 'gate_satisfied' => $gateSatisfied, + 'reason' => $reason, + 'causes' => $causes, + 'order_priority' => (int)($question['order_priority'] ?? 0), + ]; + } + + usort($items, static fn(array $a, array $b): int => ((int)$a['order_priority'] <=> (int)$b['order_priority']) ?: ((int)$a['id'] <=> (int)$b['id'])); + return $items; + } + + /** + * @param array $snapshot + * @param array> $rules + * @param array $lookups + * @return array> + */ + protected function buildDebugRules(array $snapshot, array $rules, array $lookups): array + { + $answers = is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : []; + $conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_results'] : []; + $items = []; + + foreach ($rules as $rule) { + $ruleId = (int)($rule['id'] ?? 0); + if ($ruleId <= 0) { + continue; + } + $objectType = strtolower((string)($rule['object_type'] ?? '')); + $objectId = (int)($rule['object_id'] ?? 0); + $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' => $label, + 'type' => (string)($rule['type'] ?? ''), + 'object_type' => $objectType, + 'object_id' => $objectId, + 'object_label' => $objectLabel, + 'actual_value' => $actual, + 'satisfied' => $satisfied, + 'invalid_reference' => $invalidReference, + 'reason' => $reason, + 'causes' => [ + $this->debugCause($objectType ?: 'object', $objectId, $objectLabel, $expected, $actual, $invalidReference ? 'Invalid rule reference.' : null), + ], + ]; + } + + return $items; + } + + /** + * @param array $snapshot + * @param array> $conditions + * @param array> $rules + * @param array $lookups + * @return array> + */ + protected function buildDebugConditions(array $snapshot, array $conditions, array $rules, array $lookups): array + { + $conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_results'] : []; + $expressionTraces = is_array($snapshot['evaluation_trace']['condition_expression_traces'] ?? null) ? (array)$snapshot['evaluation_trace']['condition_expression_traces'] : []; + $cycleIds = $this->detectConditionCycles($conditions, $rules); + $rulesByCondition = []; + foreach ($rules as $rule) { + $rulesByCondition[(int)($rule['condition_id'] ?? 0)][] = $rule; + } + + $items = []; + foreach ($conditions as $condition) { + $conditionId = (int)($condition['id'] ?? 0); + if ($conditionId <= 0) { + continue; + } + $result = ($conditionResults[$conditionId] ?? false) === true; + $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, + 'node_ids' => ['condition:' . $conditionId], + 'label' => $label, + 'result' => $result, + 'state' => $result ? 'passed' : 'failed', + 'parent_condition_id' => $this->nullableInt($condition['condition_id'] ?? null), + 'rules' => array_values($rulesByCondition[$conditionId] ?? []), + 'expression' => $expression, + 'expression_summary' => $expression === [] ? 'Legacy rules' : $this->debugExpressionSummary($expression, $lookups), + 'expression_trace' => $expressionTrace, + 'next_fix' => $nextFix, + 'has_cycle' => in_array($conditionId, $cycleIds, true), + '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, + ]; + } + + return $items; + } + + /** + * @param array $snapshot + * @param array> $tasks + * @param array $lookups + * @param array $gatewayWorkspace + * @return array> + */ + protected function buildDebugTasks(array $snapshot, array $tasks, array $lookups, array $gatewayWorkspace): array + { + $activeIds = array_flip(array_map(static fn(array $task): int => (int)($task['id'] ?? 0), (array)($snapshot['tasks'] ?? []))); + $activeAttachments = []; + foreach ((array)($snapshot['tasks'] ?? []) as $task) { + if (!is_array($task)) { + continue; + } + $taskId = (int)($task['id'] ?? $task['task_id'] ?? 0); + if ($taskId > 0 && is_array($task['attachments'] ?? null)) { + $activeAttachments[$taskId] = array_values($task['attachments']); + } + } + $gateTrace = []; + foreach ((array)($snapshot['evaluation_trace']['task_gates'] ?? []) as $trace) { + if (is_array($trace)) { + $gateTrace[(int)($trace['task_id'] ?? 0)] = $trace; + } + } + $conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null) + ? (array)$snapshot['evaluation_trace']['condition_results'] + : []; + $visibleAnswers = is_array($snapshot['debug_candidates']['visible_answers'] ?? null) + ? (array)$snapshot['debug_candidates']['visible_answers'] + : (is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : []); + $bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace); + $items = []; + + foreach ($tasks as $task) { + $taskId = (int)($task['id'] ?? 0); + if ($taskId <= 0) { + continue; + } + $trace = $gateTrace[$taskId] ?? []; + $services = $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)); + $bindings = []; + foreach ($services as $service) { + foreach ($bindingsByService[$service] ?? [] as $binding) { + $bindings[] = $binding; + } + } + $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, + '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' => $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' => $gateDecision['reason'], + 'causes' => $gateDecision['causes'], + ]; + } + + usort($items, static fn(array $a, array $b): int => ((int)$a['order_priority'] <=> (int)$b['order_priority']) ?: ((int)$a['id'] <=> (int)$b['id'])); + return $items; + } + + /** + * @param array $snapshot + * @param array> $actions + * @param array $lookups + * @return array> + */ + protected function buildDebugActions(array $snapshot, array $actions, array $lookups): array + { + $conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null) + ? (array)$snapshot['evaluation_trace']['condition_results'] + : []; + $lane = is_array($snapshot['lane'] ?? null) ? (array)$snapshot['lane'] : []; + $laneId = (int)($lane['id'] ?? 0); + $departmentId = (int)($lane['department'] ?? 0); + $vehicleTypeId = $this->nullableInt($snapshot['vehicle_type_id'] ?? null); + $machineTypeId = $this->nullableInt($snapshot['machine_type']['id'] ?? $lane['machine_type_id'] ?? null); + $eventModes = [ + selfserve_studio_actions::EVENT_WASH_START_COMMAND => $this->debugWashModeForStart($snapshot), + selfserve_studio_actions::EVENT_WASH_STOP_COMMAND => $this->debugWashModeForStop($snapshot), + selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED => selfserve_studio_actions::MODE_MACHINE, + ]; + $items = []; + + foreach ($actions as $row) { + if (!is_array($row)) { + continue; + } + $action = selfserve_studio_actions::normalize((array)$row); + $actionId = (int)($action['id'] ?? 0); + if ($actionId <= 0) { + continue; + } + + $expectedMode = $eventModes[(string)$action['event']] ?? selfserve_studio_actions::MODE_BOTH; + $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 ' . $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, + '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'], + 'simulated_wash_mode' => $expectedMode, + 'operation' => (string)$action['operation'], + 'operation_label' => selfserve_studio_actions::operationLabel((string)$action['operation'], $action['relay_state']), + 'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$action['operation']), + 'relay_state' => $action['relay_state'], + 'condition_id' => $conditionId, + 'condition' => $conditionLabel, + 'condition_satisfied' => $conditionSatisfied, + 'scope' => [ + 'department' => (int)$action['department'], + 'lane' => (int)$action['lane'], + 'product' => (int)$action['product'], + 'machine_type_id' => $action['machine_type_id'], + ], + 'options' => (array)$action['options'], + 'order_priority' => (int)$action['order_priority'], + 'raw' => $action, + ]; + } + + usort($items, static fn(array $a, array $b): int => strcmp((string)$a['event'], (string)$b['event']) + ?: ((int)$a['order_priority'] <=> (int)$b['order_priority']) + ?: ((int)$a['id'] <=> (int)$b['id'])); + return $items; + } + + /** + * @param array $snapshot + * @param array> $tasks + * @param array $gatewayWorkspace + * @param array $lookups + * @param array> $actions + * @return array + */ + protected function buildDebugHardware(array $snapshot, array $tasks, array $gatewayWorkspace, array $lookups, array $actions = []): array + { + $bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace); + $allowedServices = (array)($snapshot['allowed_services'] ?? []); + $missingBindings = []; + foreach ($allowedServices as $service) { + $service = strtoupper((string)$service); + if ($service !== '' && empty($bindingsByService[$service])) { + $missingBindings[] = $service; + } + } + + $laneId = (int)($snapshot['lane']['id'] ?? 0); + $laneRelaySlots = []; + foreach ((array)($gatewayWorkspace['lanes'] ?? []) as $lane) { + if (!is_array($lane) || (int)($lane['id'] ?? 0) !== $laneId) { + continue; + } + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (is_array($slot)) { + $laneRelaySlots[] = $slot; + } + } + } + $signalTimeline = $this->buildDebugSignalTimeline($snapshot, $gatewayWorkspace, $bindingsByService, $laneRelaySlots, $actions); + + $dryRunOperations = []; + if (($snapshot['allowed'] ?? false) === true) { + $dryRunOperations[] = [ + 'operation' => 'machine_relay_enable', + 'status' => 'predicted', + 'message' => 'Dry run predicts the machine relay would be enabled.', + ]; + } else { + $dryRunOperations[] = [ + 'operation' => 'machine_relay_enable', + 'status' => 'blocked', + 'message' => 'Dry run predicts no machine relay action because eligibility is blocked.', + ]; + } + + return [ + 'machine_relay_configured' => (bool)($snapshot['machine_available'] ?? false), + 'lane_relay_slots' => $laneRelaySlots, + 'allowed_services' => $allowedServices, + 'service_bindings' => $bindingsByService, + 'missing_service_bindings' => array_values($missingBindings), + 'gateways' => array_values((array)($gatewayWorkspace['gateways'] ?? [])), + 'issues' => array_values((array)($gatewayWorkspace['issues'] ?? [])), + 'restricted' => (bool)($gatewayWorkspace['restricted'] ?? false), + 'virtual' => is_array($gatewayWorkspace['virtual'] ?? null) ? (array)$gatewayWorkspace['virtual'] : [], + 'signal_timeline' => $signalTimeline, + 'dry_run_operations' => $dryRunOperations, + 'summary' => $this->debugHardwareSummary($snapshot, $missingBindings, $lookups), + ]; + } + + /** + * @param array $snapshot + * @param array $gatewayWorkspace + * @param array>> $bindingsByService + * @param array> $laneRelaySlots + * @param array> $actions + * @return array> + */ + protected function buildDebugSignalTimeline(array $snapshot, array $gatewayWorkspace, array $bindingsByService, array $laneRelaySlots, array $actions = []): array + { + $timeline = []; + $sequence = 1; + $allowed = (bool)($snapshot['allowed'] ?? false); + $machineAvailable = (bool)($snapshot['machine_available'] ?? false); + + $timeline[] = $this->debugSignalTimelineRow( + $sequence++, + 'eligibility_sync', + 'session_event', + 'SESSION', + null, + null, + [ + 'event' => 'SESSION_SYNCED', + 'allowed' => $allowed, + 'allowed_services' => array_values((array)($snapshot['allowed_services'] ?? [])), + ], + 'none', + 'sent', + null + ); + + $this->appendDebugActionSignalRows( + $timeline, + $sequence, + $actions, + selfserve_studio_actions::EVENT_WASH_START_COMMAND, + $allowed, + $gatewayWorkspace, + $bindingsByService, + $laneRelaySlots, + $snapshot + ); + + $machineRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'MACHINE', $snapshot); + $machineBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $machineRelayId, 'MACHINE'); + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + 'eligibility_sync', + 'relay_switch', + 'MACHINE', + $machineRelayId, + $machineBinding, + ['id' => $machineRelayId, 'channel' => 0, 'on' => true], + $allowed && $machineAvailable, + $allowed ? (!$machineAvailable ? 'Lane machine relay is not configured.' : null) : 'Eligibility is blocked, so the machine relay would not be enabled.' + ); + + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + 'machine_start_signal', + 'shelly_event', + 'MACHINE', + $machineRelayId, + $machineBinding, + [ + 'event' => 'input.toggle_on', + 'alternate_event' => 'switch.on', + 'input' => ['component' => 'input:0', 'state' => true], + 'switch' => ['component' => 'switch:0', 'output' => true], + 'bill_machine_wash' => true, + ], + $allowed && $machineAvailable, + $allowed ? (!$machineAvailable ? 'Lane machine signal relay is not configured.' : null) : 'Machine ON signal would not be accepted before eligibility passes.' + ); + + $this->appendDebugActionSignalRows( + $timeline, + $sequence, + $actions, + selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED, + $allowed && $machineAvailable, + $gatewayWorkspace, + $bindingsByService, + $laneRelaySlots, + $snapshot + ); + + $cleanerRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'CLEANER', $snapshot); + $cleanerBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $cleanerRelayId, 'CLEANER'); + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + 'machine_start', + 'relay_switch', + 'CLEANER', + $cleanerRelayId, + $cleanerBinding, + ['id' => $cleanerRelayId, 'channel' => 0, 'on' => true], + $allowed && $cleanerRelayId !== null, + $allowed ? 'Lane has no machine start cleaner relay configured.' : 'Machine start would not run because eligibility is blocked.' + ); + + $this->appendDebugActionSignalRows( + $timeline, + $sequence, + $actions, + selfserve_studio_actions::EVENT_WASH_STOP_COMMAND, + $allowed, + $gatewayWorkspace, + $bindingsByService, + $laneRelaySlots, + $snapshot + ); + + $exitRelayId = $this->debugRelayIdForRole($laneRelaySlots, 'EXIT', $snapshot); + $exitBinding = $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $exitRelayId, 'EXIT'); + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + 'stop', + 'relay_pulse', + 'EXIT', + $exitRelayId, + $exitBinding, + ['id' => $exitRelayId, 'on' => true, 'toggle_after' => 1], + $allowed && $exitRelayId !== null, + $allowed ? 'Lane has no STOP exit relay configured.' : 'STOP exit open would not run before a valid wash can start.' + ); + + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + 'stop', + 'relay_switch', + 'CLEANER', + $cleanerRelayId, + $cleanerBinding, + ['id' => $cleanerRelayId, 'channel' => 0, 'on' => false], + $allowed && $cleanerRelayId !== null, + $allowed ? 'Lane has no cleaner relay to turn off.' : 'Cleaner off would not run before a valid wash can start.' + ); + + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + 'stop', + 'relay_switch', + 'MACHINE', + $machineRelayId, + $machineBinding, + ['id' => $machineRelayId, 'channel' => 0, 'on' => false], + $machineRelayId !== null, + $machineRelayId === null ? 'Lane machine relay is not configured.' : null + ); + + $timeline[] = $this->debugSignalTimelineRow( + $sequence, + 'session_completion', + 'session_event', + 'SESSION', + null, + null, + [ + 'event' => 'SESSION_COMPLETED', + 'reset_lane_state' => true, + ], + 'none', + $allowed ? 'sent' : 'skipped', + $allowed ? null : 'Session completion/reset only applies after a dry-run wash can start.' + ); + + return $timeline; + } + + protected function debugWashModeForStart(array $snapshot): string + { + return in_array(selfserve_lane_services::MACHINE->name, (array)($snapshot['allowed_services'] ?? []), true) + ? selfserve_studio_actions::MODE_MACHINE + : selfserve_studio_actions::MODE_MANUAL; + } + + protected function debugWashModeForStop(array $snapshot): string + { + return ((bool)($snapshot['allowed'] ?? false) === true && (bool)($snapshot['machine_available'] ?? false) === true) + ? selfserve_studio_actions::MODE_MACHINE + : selfserve_studio_actions::MODE_MANUAL; + } + + /** + * @param array> $timeline + * @param array> $actions + * @param array $gatewayWorkspace + * @param array>> $bindingsByService + * @param array> $laneRelaySlots + * @param array $snapshot + */ + protected function appendDebugActionSignalRows( + array &$timeline, + int &$sequence, + array $actions, + string $event, + bool $eventAllowed, + array $gatewayWorkspace, + array $bindingsByService, + array $laneRelaySlots, + array $snapshot + ): void { + $eventActions = array_values(array_filter( + $actions, + static fn(array $action): bool => (string)($action['event'] ?? '') === $event + )); + usort($eventActions, static fn(array $a, array $b): int => ((int)($a['order_priority'] ?? 0) <=> (int)($b['order_priority'] ?? 0)) + ?: ((int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0))); + + foreach ($eventActions as $action) { + $raw = is_array($action['raw'] ?? null) ? (array)$action['raw'] : selfserve_studio_actions::normalize($action); + $operation = (string)($raw['operation'] ?? ''); + $relayRole = selfserve_studio_actions::relayRoleForOperation($operation); + $runtimeStage = selfserve_studio_actions::runtimeStageForEvent($event); + $isRelayOperation = selfserve_studio_actions::isRelayOperation($operation); + $isPropertyGate = in_array($relayRole, ['PROPERTY_ENTRANCE', 'PROPERTY_EXIT'], true); + $relayId = $isPropertyGate ? null : $this->debugRelayIdForRole($laneRelaySlots, $relayRole, $snapshot); + $binding = $isPropertyGate ? null : $this->debugBindingForRelayRole($gatewayWorkspace, $bindingsByService, $relayId, $relayRole); + $active = (bool)($action['active'] ?? false); + $reason = (string)($action['reason'] ?? 'Action does not match the simulated scenario.'); + $payload = [ + 'action_id' => (int)($raw['id'] ?? $action['id'] ?? 0), + 'action' => (string)($raw['name'] ?? $action['label'] ?? ''), + 'event' => $event, + 'wash_mode' => (string)($raw['wash_mode'] ?? $action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH), + 'operation' => $operation, + 'operation_label' => selfserve_studio_actions::operationLabel($operation, $raw['relay_state'] ?? null), + 'enabled' => (bool)($raw['enabled'] ?? true), + 'order_priority' => (int)($raw['order_priority'] ?? $action['order_priority'] ?? 0), + 'options' => (array)($raw['options'] ?? []), + ]; + + if ($isRelayOperation) { + $payload['id'] = $relayId; + $payload['channel'] = 0; + $payload['on'] = (bool)($raw['relay_state'] ?? true); + $signalType = 'studio_action_relay_switch'; + } elseif ($isPropertyGate) { + $payload['command'] = $relayRole === 'PROPERTY_ENTRANCE' ? 'OPEN_PROPERTY_ACCESS_GATE' : 'OPEN_PROPERTY_EXIT_GATE'; + $signalType = 'studio_action_gate_open'; + } else { + $payload['id'] = $relayId; + $payload['on'] = true; + $payload['toggle_after'] = $this->nullableInt($raw['options']['toggle_after_seconds'] ?? null) ?? 1; + $signalType = 'studio_action_relay_pulse'; + } + + if (!$active) { + $timeline[] = $this->debugSignalTimelineRow( + $sequence++, + $runtimeStage, + $signalType, + $relayRole, + $relayId, + $binding, + $payload, + $binding === null ? 'none' : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'), + 'skipped', + $reason + ); + continue; + } + + if (!$eventAllowed) { + $timeline[] = $this->debugSignalTimelineRow( + $sequence++, + $runtimeStage, + $signalType, + $relayRole, + $relayId, + $binding, + $payload, + $binding === null ? ($isPropertyGate ? 'real' : 'none') : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'), + 'blocked', + 'Action event would not fire because the dry-run scenario is blocked before this stage.' + ); + continue; + } + + if ($isPropertyGate) { + $timeline[] = $this->debugSignalTimelineRow( + $sequence++, + $runtimeStage, + $signalType, + $relayRole, + null, + null, + $payload, + 'real', + 'sent', + null + ); + continue; + } + + $timeline[] = $this->debugRelaySignalTimelineRow( + $sequence++, + $runtimeStage, + $signalType, + $relayRole, + $relayId, + $binding, + $payload, + true, + 'Action relay is not configured for the simulated lane.' + ); + } + } + + /** + * @param array $snapshot + * @param array> $laneRelaySlots + */ + protected function debugRelayIdForRole(array $laneRelaySlots, string $role, array $snapshot): ?string + { + $role = strtoupper(trim($role)); + foreach ($laneRelaySlots as $slot) { + if (!is_array($slot)) { + continue; + } + $slotRole = strtoupper(trim((string)($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? ''))); + $relayId = trim((string)($slot['relay_id'] ?? '')); + if ($slotRole === $role && $relayId !== '') { + return $relayId; + } + } + + $lane = is_array($snapshot['lane'] ?? null) ? (array)$snapshot['lane'] : []; + $field = match ($role) { + 'ENTRY' => 'relay_in_id', + 'EXIT' => 'relay_out_id', + 'MACHINE' => 'relay_machine_id', + 'PROGRAM_PICKER' => 'relay_machine_program_picker_id', + 'CLEANER' => 'relay_machine_cleaner_id', + default => '', + }; + $relayId = $field !== '' ? trim((string)($lane[$field] ?? '')) : ''; + return $relayId !== '' ? $relayId : null; + } + + /** + * @param array $gatewayWorkspace + * @param array>> $bindingsByService + * @return array|null + */ + protected function debugBindingForRelayRole(array $gatewayWorkspace, array $bindingsByService, ?string $relayId, string $role): ?array + { + if ($relayId === null || trim($relayId) === '') { + return null; + } + $role = strtoupper(trim($role)); + + foreach ((array)($bindingsByService[$role] ?? []) as $binding) { + if (is_array($binding) && (string)($binding['relay_id'] ?? '') === $relayId) { + return $binding; + } + } + + foreach ($this->debugGatewayBindingReferences($gatewayWorkspace) as $binding) { + if ((string)($binding['relay_id'] ?? '') === $relayId) { + return $binding; + } + } + + return null; + } + + /** + * @param array $binding|null + * @param array $payload + * @return array + */ + protected function debugRelaySignalTimelineRow( + int $sequence, + string $runtimeStage, + string $signalType, + string $relayRole, + ?string $relayId, + ?array $binding, + array $payload, + bool $eligible, + ?string $reason + ): array { + if ($relayId === null || trim($relayId) === '') { + return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, null, null, $payload, 'none', 'skipped', $reason); + } + if (!$eligible) { + return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, $relayId, $binding, $payload, $binding === null ? 'none' : ((bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'), 'blocked', $reason); + } + if ($binding === null) { + return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, $relayId, null, $payload, 'none', 'skipped', 'No gateway binding is available for this relay in the selected hardware mode.'); + } + + $source = (bool)($binding['virtual'] ?? false) ? 'virtual' : 'real'; + return $this->debugSignalTimelineRow($sequence, $runtimeStage, $signalType, $relayRole, $relayId, $binding, $payload, $source, $source === 'virtual' ? 'virtual_only' : 'sent', $source === 'virtual' ? 'Virtual studio hardware only; live dispatch would require a real gateway binding.' : null); + } + + /** + * @param array|null $binding + * @param array $payload + * @return array + */ + protected function debugSignalTimelineRow( + int $sequence, + string $runtimeStage, + string $signalType, + string $relayRole, + ?string $relayId, + ?array $binding, + array $payload, + string $source, + 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, + 'relay_role' => $relayRole, + 'relay_id' => $relayId, + 'target_gateway' => $binding['gateway_id'] ?? null, + 'target_gateway_label' => $binding['gateway_label'] ?? null, + 'target_binding' => $binding['node_id'] ?? null, + 'target_binding_label' => $binding['relay_label'] ?? null, + 'transport' => match ($signalType) { + 'session_event' => 'selfserve_wash_session_events', + 'shelly_event', 'machine_signal' => 'shelly_webhook_or_edge_gateway_event', + default => '/v2/devices/api/set/switch', + }, + 'payload' => $payload, + 'source' => $source, + 'virtual' => $source === 'virtual', + 'predicted_status' => $predictedStatus, + 'skip_block_reason' => $reason, + 'reason' => $reason, + 'causes' => $causes, + ]; + } + + /** + * @param array $snapshot + * @param array> $questions + * @param array> $tasks + * @param array $hardware + * @param array> $conditions + * @param array> $rules + * @return array> + */ + protected function buildDebugRecommendations(array $snapshot, array $questions, array $tasks, array $hardware, array $conditions, array $rules): array + { + $items = []; + $missingQuestions = array_values(array_filter($questions, static fn(array $question): bool => ($question['state'] ?? '') === 'missing')); + if ($missingQuestions !== []) { + $items[] = [ + 'severity' => 'error', + 'title' => 'Answer required questions', + 'message' => count($missingQuestions) . ' visible question(s) are missing answers.', + 'node_ids' => array_map(static fn(array $question): string => (string)$question['node_id'], $missingQuestions), + ]; + } + if (($snapshot['machine_available'] ?? false) !== true) { + $items[] = [ + 'severity' => 'error', + 'title' => 'Configure lane machine relay', + 'message' => 'The selected lane has no machine relay configured, so the machine cannot start.', + 'node_ids' => ['lane:' . (int)($snapshot['lane']['id'] ?? 0)], + ]; + } + if (!in_array(selfserve_lane_services::MACHINE->name, (array)($snapshot['allowed_services'] ?? []), true)) { + $items[] = [ + 'severity' => 'error', + 'title' => 'Expose MACHINE service', + 'message' => 'No active task exposes the MACHINE service for this scenario.', + 'node_ids' => array_map(static fn(array $task): string => (string)$task['node_id'], $tasks), + ]; + } + foreach ((array)($hardware['missing_service_bindings'] ?? []) as $service) { + $items[] = [ + 'severity' => 'warning', + 'title' => 'Bind gateway relay for ' . $service, + 'message' => 'The active service has no edge gateway relay binding in the studio hardware workspace.', + 'node_ids' => [], + ]; + } + foreach ($conditions as $condition) { + if (($condition['has_cycle'] ?? false) === true) { + $items[] = [ + 'severity' => 'error', + 'title' => 'Fix condition cycle', + 'message' => 'Condition "' . (string)$condition['label'] . '" depends on itself through another condition.', + 'node_ids' => [(string)$condition['node_id']], + ]; + } + } + foreach ($rules as $rule) { + if (($rule['invalid_reference'] ?? false) === true) { + $items[] = [ + 'severity' => 'error', + 'title' => 'Fix invalid rule reference', + 'message' => 'Rule "' . (string)$rule['label'] . '" references an invalid object.', + 'node_ids' => [(string)$rule['node_id']], + ]; + } + } + + if ($items === []) { + $items[] = [ + 'severity' => 'success', + 'title' => 'Flow is ready', + 'message' => 'The simulated parameters pass every dry-run check.', + 'node_ids' => ['checkpoint:eligible'], + ]; + } + + return $items; + } + + /** + * @param array $snapshot + * @param array> $recommendations + * @param array $hardware + * @return array + */ + protected function buildDebugSummary(array $snapshot, array $recommendations, array $hardware): array + { + $primary = null; + foreach ($recommendations as $recommendation) { + if (($recommendation['severity'] ?? '') === 'error') { + $primary = $recommendation; + break; + } + } + $warnings = array_values(array_filter($recommendations, static fn(array $recommendation): bool => ($recommendation['severity'] ?? '') === 'warning')); + $allowed = ($snapshot['allowed'] ?? false) === true; + + return [ + 'status' => $allowed ? ($warnings === [] ? 'allowed' : 'warning') : 'blocked', + 'allowed' => $allowed, + 'title' => $allowed ? ($warnings === [] ? 'Allowed' : 'Allowed with warnings') : 'Blocked', + 'primary_blocker' => $primary, + 'next_action' => $primary['message'] ?? ($warnings[0]['message'] ?? 'No action required.'), + 'warning_count' => count($warnings), + 'dry_run' => true, + 'hardware_ready' => (bool)($hardware['machine_relay_configured'] ?? false), + ]; + } + + /** + * @param array $snapshot + * @param array> $questions + * @param array> $conditions + * @param array> $rules + * @param array> $tasks + * @param array $hardware + * @param array $summary + * @param array $lookups + * @return array> + */ + protected function buildDebugStages(array $snapshot, array $questions, array $conditions, array $rules, array $tasks, array $hardware, array $summary, array $lookups): array + { + $missingQuestions = array_values(array_filter($questions, static fn(array $question): bool => ($question['state'] ?? '') === 'missing')); + $activeTasks = array_values(array_filter($tasks, static fn(array $task): bool => ($task['active'] ?? false) === true)); + $failedConditions = array_values(array_filter($conditions, static fn(array $condition): bool => ($condition['result'] ?? false) !== true)); + + return [ + [ + 'id' => 'input', + 'title' => 'Input normalization', + 'status' => 'ok', + 'summary' => 'Registration normalized to ' . (string)$snapshot['reg'] . '.', + 'node_ids' => ['checkpoint:start'], + 'edge_ids' => ['runtime:start-eligible'], + ], + [ + 'id' => 'scope', + 'title' => 'Lane and scope resolution', + 'status' => $snapshot['vehicle_type_id'] === null ? 'warning' : 'ok', + 'summary' => 'Lane ' . $this->debugLabel($lookups, 'lanes', $snapshot['lane']['id'] ?? null, 'selected') . ' uses ' . $this->debugLabel($lookups, 'vehicle_types', $snapshot['vehicle_type_id'] ?? null, 'automatic vehicle type') . '.', + 'node_ids' => array_values(array_filter([ + 'lane:' . (int)($snapshot['lane']['id'] ?? 0), + $snapshot['vehicle_type_id'] === null ? null : 'vehicle_type:' . (int)$snapshot['vehicle_type_id'], + isset($snapshot['machine_type']['id']) ? 'machine_type:' . (int)$snapshot['machine_type']['id'] : null, + ])), + 'edge_ids' => [], + ], + [ + 'id' => 'questions', + 'title' => 'Question visibility and answers', + 'status' => $missingQuestions === [] ? 'ok' : 'error', + 'summary' => count($questions) . ' question(s) evaluated; ' . count($missingQuestions) . ' visible question(s) missing answers.', + 'node_ids' => array_map(static fn(array $question): string => (string)$question['node_id'], $questions), + 'edge_ids' => [], + ], + [ + 'id' => 'conditions', + 'title' => 'Condition and rule evaluation', + 'status' => count(array_filter($conditions, static fn(array $condition): bool => ($condition['has_cycle'] ?? false) === true)) > 0 ? 'error' : 'ok', + 'summary' => count($conditions) . ' condition(s) and ' . count($rules) . ' rule(s) evaluated; ' . count($failedConditions) . ' condition(s) false.', + 'node_ids' => array_merge( + array_map(static fn(array $condition): string => (string)$condition['node_id'], $conditions), + array_map(static fn(array $rule): string => (string)$rule['node_id'], $rules), + ), + 'edge_ids' => [], + ], + [ + 'id' => 'tasks', + 'title' => 'Task gates and services', + 'status' => in_array(selfserve_lane_services::MACHINE->name, (array)($snapshot['allowed_services'] ?? []), true) ? 'ok' : 'error', + 'summary' => count($activeTasks) . ' task(s) active; services: ' . implode(', ', (array)($snapshot['allowed_services'] ?? [])), + 'node_ids' => array_map(static fn(array $task): string => (string)$task['node_id'], $tasks), + 'edge_ids' => [], + ], + [ + 'id' => 'hardware', + 'title' => 'Gateway and relay readiness', + 'status' => ($snapshot['machine_available'] ?? false) === true ? (((array)($hardware['missing_service_bindings'] ?? [])) === [] ? 'ok' : 'warning') : 'error', + 'summary' => (string)($hardware['summary'] ?? ''), + 'node_ids' => [], + 'edge_ids' => [], + ], + [ + 'id' => 'final', + 'title' => 'Final eligibility decision', + 'status' => ($summary['status'] ?? '') === 'blocked' ? 'error' : (($summary['status'] ?? '') === 'warning' ? 'warning' : 'ok'), + 'summary' => (string)($summary['next_action'] ?? ''), + 'node_ids' => ['checkpoint:eligible', 'checkpoint:finish'], + 'edge_ids' => ['runtime:eligible-finish'], + ], + ]; + } + + /** + * @param array $snapshot + * @param array> $questions + * @param array> $conditions + * @param array> $rules + * @param array> $tasks + * @param array> $actions + * @param array $hardware + * @param array $graph + * @return array + */ + protected function buildGraphAnnotations(array $snapshot, array $questions, array $conditions, array $rules, array $tasks, array $actions, array $hardware, array $graph): array + { + $nodes = [ + 'checkpoint:start' => ['state' => 'visited', 'label' => 'Simulation started'], + 'checkpoint:eligible' => ['state' => ($snapshot['allowed'] ?? false) ? 'active' : 'blocked', 'label' => 'Eligibility resolved'], + 'checkpoint:finish' => ['state' => ($snapshot['allowed'] ?? false) ? 'visited' : 'not_applicable', 'label' => 'Predicted completion checkpoint'], + 'lane:' . (int)($snapshot['lane']['id'] ?? 0) => ['state' => 'active', 'label' => 'Selected lane'], + ]; + if ($snapshot['vehicle_type_id'] !== null) { + $nodes['vehicle_type:' . (int)$snapshot['vehicle_type_id']] = ['state' => 'active', 'label' => 'Selected vehicle type']; + } + if (isset($snapshot['machine_type']['id'])) { + $nodes['machine_type:' . (int)$snapshot['machine_type']['id']] = ['state' => 'active', 'label' => 'Resolved machine type']; + } + + foreach ($questions as $question) { + $state = match ($question['state'] ?? '') { + 'answered' => 'active', + 'missing' => 'warning', + default => 'not_applicable', + }; + $nodes[(string)$question['node_id']] = ['state' => $state, 'label' => (string)$question['reason']]; + } + foreach ($conditions as $condition) { + $nodes[(string)$condition['node_id']] = [ + 'state' => ($condition['has_cycle'] ?? false) ? 'error' : (($condition['result'] ?? false) ? 'active' : 'blocked'), + 'label' => (string)$condition['reason'], + ]; + } + foreach ($rules as $rule) { + $nodes[(string)$rule['node_id']] = [ + 'state' => ($rule['invalid_reference'] ?? false) ? 'error' : (($rule['satisfied'] ?? false) ? 'active' : 'blocked'), + 'label' => (string)$rule['reason'], + ]; + } + foreach ($tasks as $task) { + $nodes[(string)$task['node_id']] = [ + 'state' => ($task['active'] ?? false) ? 'active' : 'blocked', + 'label' => (string)$task['reason'], + ]; + foreach ((array)($task['relay_bindings'] ?? []) as $binding) { + if (isset($binding['node_id'])) { + $nodes[(string)$binding['node_id']] = ['state' => ($task['active'] ?? false) ? 'active' : 'visited', 'label' => 'Relay binding for active service']; + } + if (isset($binding['gateway_node_id'])) { + $nodes[(string)$binding['gateway_node_id']] = ['state' => 'visited', 'label' => 'Gateway available for service']; + } + if (isset($binding['relay_node_id'])) { + $nodes[(string)$binding['relay_node_id']] = ['state' => 'visited', 'label' => 'Hardware relay available for service']; + } + } + } + foreach ($actions as $action) { + $nodes[(string)$action['node_id']] = [ + 'state' => ($action['active'] ?? false) ? 'active' : 'not_applicable', + 'label' => (string)($action['reason'] ?? 'Action evaluated.'), + ]; + } + + $edges = []; + foreach ((array)($graph['edges'] ?? []) as $edge) { + if (!is_array($edge)) { + continue; + } + $edgeId = (string)($edge['id'] ?? ''); + $source = (string)($edge['source'] ?? ''); + $target = (string)($edge['target'] ?? ''); + if ($edgeId === '' || (!isset($nodes[$source]) && !isset($nodes[$target]))) { + continue; + } + $sourceState = (string)($nodes[$source]['state'] ?? 'visited'); + $targetState = (string)($nodes[$target]['state'] ?? 'visited'); + $edges[$edgeId] = [ + 'state' => $this->mergeAnnotationStates($sourceState, $targetState), + 'label' => (string)($edge['label'] ?? 'Simulated relationship'), + ]; + } + + if (($snapshot['machine_available'] ?? false) !== true) { + $nodes['lane:' . (int)($snapshot['lane']['id'] ?? 0)] = ['state' => 'error', 'label' => 'Lane machine relay is not configured.']; + } + + return [ + 'nodes' => $nodes, + 'edges' => $edges, + ]; + } + + protected function debugRuleSatisfied(string $type, mixed $actual): bool + { + return match (strtoupper(trim($type))) { + 'IS_TRUE', 'IS_TRUE_OR_ANY_TRUE' => $actual === true, + 'IS_FALSE' => $actual === false, + 'IS_SET' => $actual !== null, + 'IS_TRUE_OR_NOT_SET' => $actual === true || $actual === null, + 'IS_FALSE_OR_NOT_SET' => $actual === false || $actual === null, + default => false, + }; + } + + 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 + */ + 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 $trace + * @param array $lookups + * @return array|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 $conditionResults + * @param array $visibleAnswers + * @return array{reason:string,causes:array>} + */ + 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> $tasks + * @return array> + */ + 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> $questions + * @param array> $conditions + * @param array> $rules + * @param array> $tasks + * @param array> $actions + * @param array> $signals + * @param array> $dynamicImageButtons + * @return array> + */ + 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 $item + * @return array + */ + 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> $conditions + * @param array> $rules + * @return array + */ + protected function detectConditionCycles(array $conditions, array $rules): array + { + $edges = []; + foreach ($conditions as $condition) { + $conditionId = (int)($condition['id'] ?? 0); + $parentId = $this->nullableInt($condition['condition_id'] ?? null); + if ($conditionId > 0 && $parentId !== null) { + $edges[$conditionId][] = $parentId; + } + } + foreach ($rules as $rule) { + if (strtolower((string)($rule['object_type'] ?? '')) !== 'condition') { + continue; + } + $conditionId = (int)($rule['condition_id'] ?? 0); + $objectId = (int)($rule['object_id'] ?? 0); + if ($conditionId > 0 && $objectId > 0) { + $edges[$conditionId][] = $objectId; + } + } + + $visiting = []; + $visited = []; + $cycles = []; + $walk = function (int $conditionId) use (&$walk, &$visiting, &$visited, &$cycles, $edges): void { + if (isset($visited[$conditionId])) { + return; + } + if (isset($visiting[$conditionId])) { + $cycles[$conditionId] = $conditionId; + return; + } + $visiting[$conditionId] = true; + foreach ($edges[$conditionId] ?? [] as $nextId) { + $walk((int)$nextId); + if (isset($cycles[(int)$nextId])) { + $cycles[$conditionId] = $conditionId; + } + } + unset($visiting[$conditionId]); + $visited[$conditionId] = true; + }; + + foreach (array_keys($edges) as $conditionId) { + $walk((int)$conditionId); + } + + return array_values($cycles); + } + + /** + * @param array $expression + * @param array $lookups + */ + protected function debugExpressionSummary(array $expression, array $lookups): string + { + $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group')); + if ($type === 'predicate') { + $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? '')); + $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0); + $operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? 'IS_TRUE')); + $lookupType = $subjectType === 'condition' ? 'conditions' : 'questions'; + $label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId); + return $label . ' ' . strtolower(str_replace('_', ' ', $operator)); + } + + $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL')); + $children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : []; + if ($children === []) { + return 'No predicates'; + } + + $parts = []; + foreach (array_slice($children, 0, 3) as $child) { + if (is_array($child)) { + $parts[] = $this->debugExpressionSummary((array)$child, $lookups); + } + } + if (count($children) > 3) { + $parts[] = '+' . (count($children) - 3) . ' more'; + } + + return ($operator === 'ANY' ? 'Any of: ' : 'All of: ') . implode('; ', $parts); + } + + /** + * @param array $trace + * @param array $lookups + */ + protected function nextFixForExpressionTrace(array $trace, array $lookups): ?string + { + $failed = $this->firstFailedPredicateTrace((array)($trace['expression'] ?? $trace)); + if ($failed === null) { + return null; + } + + $subjectType = strtolower((string)($failed['subject_type'] ?? 'question')); + $subjectId = (int)($failed['subject_id'] ?? 0); + $operator = strtoupper((string)($failed['operator'] ?? 'IS_TRUE')); + $lookupType = $subjectType === 'condition' ? 'conditions' : 'questions'; + $label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId); + + return 'Set ' . $label . ' so it satisfies ' . strtolower(str_replace('_', ' ', $operator)) . '.'; + } + + /** + * @param array $trace + * @return array|null + */ + protected function firstFailedPredicateTrace(array $trace): ?array + { + if (($trace['type'] ?? '') === 'predicate') { + return (($trace['result'] ?? false) === true) ? null : $trace; + } + foreach ((array)($trace['children'] ?? []) as $child) { + if (!is_array($child)) { + continue; + } + $failed = $this->firstFailedPredicateTrace((array)$child); + if ($failed !== null) { + 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; + } + + /** + * @param array $workspace + * @return array> + */ + protected function debugRelayServicesFromWorkspace(array $workspace): array + { + $servicesByRelay = []; + foreach ((array)($workspace['lanes'] ?? []) as $lane) { + if (!is_array($lane)) { + continue; + } + foreach ((array)($lane['relay_slots'] ?? []) as $slot) { + if (!is_array($slot)) { + continue; + } + $relayId = trim((string)($slot['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + foreach ($this->debugNormalizeServiceValues($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? '') as $service) { + $servicesByRelay[$relayId][$service] = true; + } + } + } + + return array_map(static fn(array $services): array => array_keys($services), $servicesByRelay); + } + + /** + * @param array $binding + * @param array> $relayServices + * @return array + */ + protected function debugBindingServices(array $binding, string $relayId, array $relayServices): array + { + $services = []; + $addServices = function (mixed $value) use (&$services): void { + foreach ($this->debugNormalizeServiceValues($value) as $service) { + $services[$service] = true; + } + }; + + foreach (['role', 'service', 'slot'] as $field) { + $addServices($binding[$field] ?? null); + } + $addServices($binding['services'] ?? []); + + foreach ($this->debugBindingConsumerContexts($binding) as $context) { + if (!is_array($context)) { + continue; + } + foreach (['slot', 'role', 'service'] as $field) { + $addServices($context[$field] ?? null); + } + } + + foreach ((array)($relayServices[$relayId] ?? []) as $service) { + $addServices($service); + } + + return array_keys($services); + } + + /** + * @param array $binding + * @return array + */ + protected function debugBindingConsumerContexts(array $binding): array + { + $contexts = []; + foreach (['consumer_contexts', 'consumers'] as $field) { + foreach ((array)($binding[$field] ?? []) as $context) { + $contexts[] = $context; + } + } + $metadata = is_array($binding['metadata'] ?? null) ? (array)$binding['metadata'] : []; + foreach (['consumer_contexts', 'consumers'] as $field) { + foreach ((array)($metadata[$field] ?? []) as $context) { + $contexts[] = $context; + } + } + + return $contexts; + } + + /** + * @return array + */ + protected function debugNormalizeServiceValues(mixed $value): array + { + if (is_string($value)) { + $trimmed = trim($value); + if ($trimmed === '') { + return []; + } + $decoded = json_decode($trimmed, true); + $value = json_last_error() === JSON_ERROR_NONE && is_array($decoded) + ? $decoded + : explode(',', $trimmed); + } + + if (!is_array($value)) { + $value = [$value]; + } + + $services = []; + foreach ($value as $entry) { + if (is_array($entry)) { + continue; + } + $service = strtoupper(trim((string)$entry)); + if ($service !== '') { + $services[$service] = true; + } + } + + return array_keys($services); + } + + /** + * @param array $workspace + * @return array>> + */ + protected function debugGatewayBindingsByService(array $workspace): array + { + $bindings = []; + foreach ($this->debugGatewayBindingReferences($workspace) as $binding) { + foreach ((array)($binding['services'] ?? []) as $service) { + $row = $binding; + $row['service'] = $service; + $bindings[$service][] = $row; + } + } + + return $bindings; + } + + /** + * @param array $workspace + * @return array> + */ + protected function debugGatewayBindingReferences(array $workspace): array + { + $references = []; + $relayServices = $this->debugRelayServicesFromWorkspace($workspace); + foreach ((array)($workspace['gateways'] ?? []) as $gateway) { + if (!is_array($gateway)) { + continue; + } + $gatewayId = $this->debugGatewayIdentifier($gateway); + if ($gatewayId === '') { + continue; + } + foreach ((array)($gateway['bindings'] ?? []) as $index => $binding) { + if (!is_array($binding)) { + continue; + } + $relayId = trim((string)($binding['relay_id'] ?? '')); + if ($relayId === '') { + continue; + } + $services = $this->debugBindingServices($binding, $relayId, $relayServices); + if ($services === []) { + continue; + } + $references[] = [ + 'gateway_id' => $gatewayId, + 'gateway_label' => (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)), + 'gateway_status' => (string)($gateway['status'] ?? 'UNKNOWN'), + 'gateway_node_id' => (string)($gateway['node_id'] ?? ('gateway:' . $gatewayId)), + 'relay_id' => $relayId, + 'relay_label' => (string)($binding['label'] ?? ('Relay ' . $relayId)), + 'relay_node_id' => 'relay:' . $relayId, + 'binding_index' => (int)$index, + 'node_id' => (string)($binding['node_id'] ?? ('binding:' . $gatewayId . ':' . $relayId . ':' . (int)$index)), + 'services' => $services, + 'channel' => $binding['channel'] ?? null, + 'virtual' => (bool)($gateway['virtual'] ?? $binding['virtual'] ?? false), + ]; + } + } + + return $references; + } + + /** + * @param array $gateway + */ + protected function debugGatewayIdentifier(array $gateway): string + { + return trim((string)($gateway['key'] ?? $gateway['gateway_key'] ?? $gateway['id'] ?? '')); + } + + /** + * @param array $snapshot + * @param array $missingBindings + * @param array $lookups + */ + protected function debugHardwareSummary(array $snapshot, array $missingBindings, array $lookups): string + { + if (($snapshot['machine_available'] ?? false) !== true) { + return 'Lane ' . $this->debugLabel($lookups, 'lanes', $snapshot['lane']['id'] ?? null, 'selected') . ' has no machine relay configured.'; + } + if ($missingBindings !== []) { + return 'Lane relay is configured, but gateway bindings are missing for: ' . implode(', ', $missingBindings) . '.'; + } + return 'Lane relay and gateway service bindings are ready for the simulated services.'; + } + + /** + * @param array $lookups + */ + protected function debugLabel(array $lookups, string $type, mixed $id, string $fallback): string + { + $key = (string)($id ?? ''); + if ($key === '' || $key === '0') { + return $fallback; + } + if (isset($lookups['labels'][$type][$key])) { + return (string)$lookups['labels'][$type][$key]; + } + foreach ((array)($lookups[$type] ?? []) as $row) { + if (is_array($row) && (string)($row['id'] ?? '') === $key) { + return (string)($row['label'] ?? $fallback); + } + } + return $fallback; + } + + /** + * @param array $lookups + */ + protected function debugGateReferenceLabel(array $lookups, string $gateType, int $gateRefId): string + { + $gateType = strtoupper(trim($gateType)); + if ($gateType === selfserve_task_gate_type::CONDITION->value) { + return $this->debugLabel($lookups, 'conditions', $gateRefId, 'Condition ' . $gateRefId); + } + if ($gateType === selfserve_task_gate_type::QUESTION->value) { + return $this->debugLabel($lookups, 'questions', $gateRefId, 'Question ' . $gateRefId); + } + return 'Always'; + } + + protected function mergeAnnotationStates(string $left, string $right): string + { + $rank = [ + 'error' => 6, + 'blocked' => 5, + 'warning' => 4, + 'active' => 3, + 'visited' => 2, + 'not_applicable' => 1, + ]; + return (($rank[$left] ?? 0) >= ($rank[$right] ?? 0)) ? $left : $right; + } + /** * @param array|null $publishedConfig */ @@ -686,6 +3068,57 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return $tasksObject->getLegacyTasksForLaneProduct($departmentId, $laneId, $vehicleTypeId); } + /** + * @param array $task + * @param array $conditionIds + * @return array{gate_type:selfserve_task_gate_type,gate_ref_id:int|null} + */ + protected function resolveTaskGate(array $task, array $conditionIds): array + { + $gateType = selfserve_task_gate_type::tryFrom(strtoupper(trim((string)($task['gate_type'] ?? '')))); + $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null); + $legacyGateId = $this->nullableInt($task['condition_id'] ?? null); + + if ( + $gateType === selfserve_task_gate_type::CONDITION + || $gateType === selfserve_task_gate_type::QUESTION + ) { + return [ + 'gate_type' => $gateType, + 'gate_ref_id' => $gateRefId ?? $legacyGateId, + ]; + } + + $shouldInferLegacyGate = $gateType === null + || ( + $gateType === selfserve_task_gate_type::ALWAYS + && $gateRefId === null + && $legacyGateId !== null + ); + + if ($shouldInferLegacyGate) { + $fallbackGateId = $gateRefId ?? $legacyGateId; + if ($fallbackGateId === null) { + return [ + 'gate_type' => selfserve_task_gate_type::ALWAYS, + 'gate_ref_id' => null, + ]; + } + + return [ + 'gate_type' => in_array($fallbackGateId, $conditionIds, true) + ? selfserve_task_gate_type::CONDITION + : selfserve_task_gate_type::QUESTION, + 'gate_ref_id' => $fallbackGateId, + ]; + } + + return [ + 'gate_type' => selfserve_task_gate_type::ALWAYS, + 'gate_ref_id' => null, + ]; + } + /** * @param array|null $publishedConfig */ @@ -695,6 +3128,10 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return []; } + if (is_array($publishedConfig) && (int)($publishedConfig['schema_version'] ?? 0) === selfserve_config_versioning::SCHEMA_VERSION_V2) { + return []; + } + $conditionIds = array_map(static fn(array $condition): int => (int)$condition['id'], $conditions); if (is_array($publishedConfig) && isset($publishedConfig['rules']) && is_array($publishedConfig['rules'])) { @@ -922,8 +3359,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i 'deleted_at' => null, ...($customerNumber !== null ? ['customer_number' => $customerNumber] : []), ], - ['id'] + ['id', 'status'] ); + $rows = array_values(array_filter( + $rows, + static fn(array $row): bool => !selfserve_wash_sessions_o::isTerminalStatus($row['status'] ?? null) + )); if ($rows === []) { return new selfserve_wash_sessions_o(); } @@ -932,6 +3373,48 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return (new selfserve_wash_sessions_o())->select((int)$rows[0]['id']); } + protected function resolveForceStopSession(int $laneId, ?int $sessionId = null): selfserve_wash_sessions_o + { + if ($sessionId === null) { + return (new selfserve_wash_sessions_o())->selectLatestOpenByLane($laneId); + } + + $session = (new selfserve_wash_sessions_o())->select($sessionId); + if (!$session->exists()) { + throw new \RuntimeException('Self-serve wash session not found.'); + } + if ((int)$session->lane_id->value() !== $laneId) { + throw new \RuntimeException('Self-serve wash session does not belong to the requested lane.'); + } + if ($session->completed_at->value() !== null) { + throw new \RuntimeException('Self-serve wash session is already closed.'); + } + + return $session; + } + + protected function buildForceStopRuntimeSnapshot(selfserve_lane $lane): array + { + return [ + 'status' => $lane->getLaneStatus()->name, + 'mode' => $lane->getLaneMode()->name, + 'state' => $lane->getLaneState()->name, + 'wash_start_time' => $lane->getWashStartTime(), + 'elapsed_wash_time' => $lane->getElapsedWashTime(), + 'license_plate' => $lane->getLicensePlate(), + 'customer_number' => $lane->getCustomerNumber(), + ]; + } + + protected function laneRuntimeLooksActive(array $snapshot): bool + { + return $snapshot['status'] === selfserve_lane_status::OCCUPIED->name + || $snapshot['state'] === selfserve_lane_state::IN_WASH->name + || (int)($snapshot['wash_start_time'] ?? 0) > 0 + || trim((string)($snapshot['license_plate'] ?? '')) !== '' + || (int)($snapshot['customer_number'] ?? 0) > 0; + } + protected function nullableInt(mixed $value): ?int { if ($value === null || $value === '' || $value === 0 || $value === '0') { @@ -982,6 +3465,46 @@ class selfserve_wash_flow implements selfserve_wash_flow_i return is_array($decoded) ? $decoded : []; } + protected function normalizeButtonList(mixed $value): array + { + try { + return department_selfserve_tasks_o::normalizeButtonsInput($value); + } catch (\Throwable) { + return []; + } + } + + /** + * @param array> $tasks + * @param array $allowedServices + * @return array> + */ + protected function filterTasksForAllowedServices(array $tasks, array $allowedServices): array + { + if (in_array(selfserve_lane_services::MACHINE->name, $allowedServices, true)) { + return array_values($tasks); + } + + return array_values(array_filter( + $tasks, + fn(array $task): bool => !$this->taskUsesMachineControls($task) + )); + } + + protected function taskUsesMachineControls(array $task): bool + { + if (in_array(selfserve_lane_services::MACHINE->name, $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), true)) { + return true; + } + + if ($this->normalizeButtonList($task['buttons'] ?? null) !== []) { + return true; + } + + $dynamicImagesVehicleType = $task['dynamic_images_vehicle_type'] ?? null; + return $dynamicImagesVehicleType !== null && $dynamicImagesVehicleType !== ''; + } + protected function normalizeServiceNames(array $services): array { $normalized = []; diff --git a/services/nginx/app/modules/selfserve/helpers/selfserve_lane_services.php b/services/nginx/app/modules/selfserve/helpers/selfserve_lane_services.php index 2dc4c6ac..c59c2ea6 100644 --- a/services/nginx/app/modules/selfserve/helpers/selfserve_lane_services.php +++ b/services/nginx/app/modules/selfserve/helpers/selfserve_lane_services.php @@ -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 } diff --git a/services/nginx/app/modules/selfserve/helpers/selfserve_wash_event_type.php b/services/nginx/app/modules/selfserve/helpers/selfserve_wash_event_type.php index 4f749930..242e3e70 100644 --- a/services/nginx/app/modules/selfserve/helpers/selfserve_wash_event_type.php +++ b/services/nginx/app/modules/selfserve/helpers/selfserve_wash_event_type.php @@ -8,4 +8,5 @@ enum selfserve_wash_event_type: string case MACHINE_RELAY_ENABLED = 'MACHINE_RELAY_ENABLED'; case MACHINE_START_TRIGGERED = 'MACHINE_START_TRIGGERED'; case SESSION_COMPLETED = 'SESSION_COMPLETED'; + case SESSION_FORCE_STOPPED = 'SESSION_FORCE_STOPPED'; } diff --git a/services/nginx/app/modules/selfserve/helpers/selfserve_wash_session_status.php b/services/nginx/app/modules/selfserve/helpers/selfserve_wash_session_status.php index 282d083a..43076971 100644 --- a/services/nginx/app/modules/selfserve/helpers/selfserve_wash_session_status.php +++ b/services/nginx/app/modules/selfserve/helpers/selfserve_wash_session_status.php @@ -10,4 +10,5 @@ enum selfserve_wash_session_status: string case MACHINE_RELAY_ENABLED = 'MACHINE_RELAY_ENABLED'; case MACHINE_STARTED = 'MACHINE_STARTED'; case COMPLETED = 'COMPLETED'; + case FORCE_STOPPED = 'FORCE_STOPPED'; } diff --git a/services/nginx/app/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php b/services/nginx/app/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php index ed59513e..bb21a541 100644 --- a/services/nginx/app/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php +++ b/services/nginx/app/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php @@ -12,6 +12,20 @@ interface selfserve_condition_evaluator_i */ public function evaluate(array $conditions, array $rules, array $answers): array; + /** + * @param array> $conditions + * @param array $answers + * @return array + */ + public function evaluateExpressions(array $conditions, array $answers): array; + + /** + * @param array> $conditions + * @param array $answers + * @return array{results:array,trace:array>} + */ + public function evaluateExpressionsWithTrace(array $conditions, array $answers): array; + /** * @param int|null $gateId * @param array $conditionResults diff --git a/services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php b/services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php index 7804b9c4..587384dc 100644 --- a/services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php +++ b/services/nginx/app/modules/selfserve/interfaces/selfserve_wash_flow_i.php @@ -4,9 +4,9 @@ namespace modules\selfserve\interfaces; interface selfserve_wash_flow_i { - public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null): array; + public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null, array $options = []): array; - public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null): array; + public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array; public function recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = []): array; @@ -15,4 +15,6 @@ interface selfserve_wash_flow_i public function getLatestSessionSummary(int $laneId, string $reg): array; public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array; + + public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array; } diff --git a/services/nginx/app/modules/selfserve/selfserve.md b/services/nginx/app/modules/selfserve/selfserve.md index b7afdf4b..15140678 100644 --- a/services/nginx/app/modules/selfserve/selfserve.md +++ b/services/nginx/app/modules/selfserve/selfserve.md @@ -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: @@ -311,12 +311,12 @@ Purpose: preview whether self-serve is currently allowed for a vehicle on a lane | Method | Required params | Permissions | Notes | | --- | --- | --- | --- | -| `GET /department/selfserve/vehicle/allowed` | `lane_id`, `reg` | `list_department_selfserve_vehicle_conditions` or `list_own_department_selfserve_vehicle_conditions` | Calls `selfserve_wash_flow::previewVehicleEligibility()` and returns questions, tasks, allowed services, and the current session if one exists. | +| `GET /department/selfserve/vehicle/allowed` | `lane_id`, `reg` | `list_department_selfserve_vehicle_conditions` or `list_own_department_selfserve_vehicle_conditions` | Calls `selfserve_wash_flow::previewVehicleEligibility()` and returns questions, tasks, allowed services, and the current session if one exists. Own-permission customers may evaluate borrowed plates; saved answers only apply when scoped to the authenticated customer. | Typical failures: - `400` missing `lane_id` or `reg` -- `403` permission denied or wrong vehicle ownership +- `403` permission denied, missing customer context, or wrong department - `404` lane not found ### `/department/selfserve/washes/summary` @@ -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 diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_cache_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_cache_t.php index 1adf8a29..817488ae 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_cache_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_cache_t.php @@ -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; } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php index 0d23b83f..8a5a06a7 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php @@ -9,11 +9,21 @@ 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'; +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; use modules\selfserve\classes\selfserve_lane_command_arguments; +use modules\selfserve\classes\selfserve_studio_action_runner; +use modules\selfserve\classes\selfserve_studio_actions; use modules\selfserve\classes\selfserve_wash_flow; use modules\selfserve\helpers\selfserve_lane_command; use modules\selfserve\helpers\selfserve_lane_log_action; @@ -29,7 +39,7 @@ use objects\department_variables_o; trait selfserve_lane_command_t { /** - * Determine if the lane's department has self-serve enabled. + * Determine if the lane and its department have self-serve enabled. * This method is intentionally protected to allow tests to override * and avoid I/O when needed. */ @@ -42,7 +52,8 @@ trait selfserve_lane_command_t $departmentId = (int)$this->department_lane->department->value(); if ($departmentId <= 0) return false; $vars = (new department_variables_o())->selectDepartment($departmentId); - return $vars->getVariable('selfserve_enabled') === true; + return $vars->getVariable('selfserve_enabled') === true + && $this->department_lane->isSelfServeEnabled(); } catch (\Throwable $e) { // If anything goes wrong, default to not enabled return false; @@ -63,6 +74,23 @@ trait selfserve_lane_command_t } } + /** + * Machine-wash billing is based on the physical machine ON signal, not selector relay status. + */ + protected function hasMachineStartSignalForStop(): bool + { + try { + $customerNumber = method_exists($this, 'getCustomerNumber') ? (int)$this->getCustomerNumber() : null; + return (new selfserve_wash_flow())->hasMachineStartTriggeredForLane( + (int)$this->id, + method_exists($this, 'getLicensePlate') ? ($this->getLicensePlate() ?: null) : null, + $customerNumber !== null && $customerNumber > 0 ? $customerNumber : null + ); + } catch (\Throwable) { + return false; + } + } + /** * Append the lane vehicle-type product to the current invoice order when requested. */ @@ -122,6 +150,193 @@ trait selfserve_lane_command_t } } + protected function openEntrancePortForWashStart(): void + { + try { + $this->open(selfserve_lane_port::ENTRANCE); + } catch (\Throwable $e) { + if ($this->isAmbiguousGatewayTimeout($e)) { + $this->reportWashStartEntranceTimeout($e); + return; + } + + throw $e; + } + } + + protected function isAmbiguousGatewayTimeout(\Throwable $e): bool + { + $current = $e; + while ($current !== null) { + $message = strtolower(trim($current->getMessage())); + if ( + str_contains($message, 'edge gateway command timed out') || + str_contains($message, 'command timed out') || + str_contains($message, 'timed out') || + str_contains($message, 'timeout') + ) { + return true; + } + + $current = $current->getPrevious(); + } + + return false; + } + + protected function reportWashStartEntranceTimeout(\Throwable $e): void + { + try { + $laneId = isset($this->id) ? (string)$this->id : 'unknown'; + error_log( + 'Self-serve START entrance gate dispatch timed out for lane ' . + $laneId . + '; continuing wash start because the gateway command may already have reached the relay: ' . + $e->getMessage() + ); + } catch (\Throwable) { + // Diagnostics must not block the user wash start flow. + } + } + + protected function openExitPortForWashStop(): void + { + try { + $this->open(selfserve_lane_port::EXIT); + } catch (\Throwable $e) { + if ($this->isAmbiguousGatewayTimeout($e)) { + $this->reportWashStopExitTimeout($e); + return; + } + + throw $e; + } + } + + protected function reportWashStopExitTimeout(\Throwable $e): void + { + try { + $laneId = isset($this->id) ? (string)$this->id : 'unknown'; + error_log( + 'Self-serve STOP exit gate dispatch timed out for lane ' . + $laneId . + '; continuing wash stop because the gateway command may already have reached the relay: ' . + $e->getMessage() + ); + } catch (\Throwable) { + // Diagnostics must not block the user wash stop flow. + } + } + + protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void + { + if ($arguments->defer_relay_side_effects) { + return; + } + + // Ensure cleaner relay is enabled whenever wash starts. + $this->turnOnCleanerRelayForWashStart(); + // Ensure the machine relay is ON when a wash starts, when it is allowed. + $this->setMachineRelayStatusForWashStart(); + } + + protected function resolveSelfServeActionWashModeForStart(): string + { + try { + if (method_exists($this, 'getLaneCache') && defined(self::class . '::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES')) { + $services = $this->getLaneCache((int)$this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES); + if (is_array($services)) { + foreach ($services as $service) { + if (strtoupper((string)$service) === 'MACHINE') { + return selfserve_studio_actions::MODE_MACHINE; + } + } + } + } + } catch (\Throwable) { + // Fall through to manual mode when the cached service set is unavailable. + } + + return selfserve_studio_actions::MODE_MANUAL; + } + + /** + * Execute configured Studio actions from the published flow for this lane. + * + * @param array $context + * @return array> + */ + protected function runPublishedStudioActions(string $event, string $washMode, array $context = []): array + { + return (new selfserve_studio_action_runner())->executeForLaneEvent( + $this, + $event, + $washMode, + $this->buildPublishedStudioActionContext($context) + ); + } + + /** + * @param array $context + * @return array + */ + protected function buildPublishedStudioActionContext(array $context): array + { + if (!array_key_exists('lane_id', $context)) { + $context['lane_id'] = (int)$this->id; + } + + $reg = trim((string)($context['reg'] ?? '')); + if ($reg === '' && method_exists($this, 'getLicensePlate')) { + $reg = trim((string)$this->getLicensePlate()); + if ($reg !== '') { + $context['reg'] = $reg; + } + } + + $customerNumber = $context['customer_number'] ?? null; + if (($customerNumber === null || (int)$customerNumber <= 0) && method_exists($this, 'getCustomerNumber')) { + $resolvedCustomerNumber = (int)$this->getCustomerNumber(); + if ($resolvedCustomerNumber > 0) { + $customerNumber = $resolvedCustomerNumber; + $context['customer_number'] = $resolvedCustomerNumber; + } + } + + if ($reg === '') { + return $context; + } + + try { + $preview = (new selfserve_wash_flow())->previewVehicleEligibility( + (int)$this->id, + $reg, + $customerNumber === null || (int)$customerNumber <= 0 ? null : (int)$customerNumber + ); + if (!isset($context['condition_results']) && is_array($preview['evaluation_trace']['condition_results'] ?? null)) { + $context['condition_results'] = (array)$preview['evaluation_trace']['condition_results']; + } + if (!isset($context['visibility_condition_results']) && is_array($preview['evaluation_trace']['visibility_condition_results'] ?? null)) { + $context['visibility_condition_results'] = (array)$preview['evaluation_trace']['visibility_condition_results']; + } + if (!isset($context['allowed_services']) && is_array($preview['allowed_services'] ?? null)) { + $context['allowed_services'] = (array)$preview['allowed_services']; + } + if (!isset($context['vehicle_type_id']) && array_key_exists('vehicle_type_id', $preview)) { + $context['vehicle_type_id'] = $preview['vehicle_type_id']; + } + if (!isset($context['product']) && array_key_exists('vehicle_type_id', $preview)) { + $context['product'] = $preview['vehicle_type_id']; + } + if (!isset($context['machine_type_id']) && is_array($preview['machine_type'] ?? null)) { + $context['machine_type_id'] = $preview['machine_type']['id'] ?? null; + } + } catch (\Throwable) { + // Studio actions remain best-effort for legacy command flows. + } + + return $context; + } /** * Disable relays after STOP in deterministic order: @@ -186,7 +401,7 @@ trait selfserve_lane_command_t $gateLabel = $isAccessGate ? 'entrance' : 'exit'; if (!$this->isDepartmentSelfServeEnabled()) { - throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: Self-serve is not enabled for this lane\'s department.'); + throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: Self-serve is not enabled for this lane.'); } if (empty($this->department_lane) || empty($this->department_lane->department)) { throw new \RuntimeException('Cannot open property ' . $commandLabel . ' gate: Lane department is not configured.'); @@ -249,6 +464,7 @@ trait selfserve_lane_command_t * @param selfserve_lane_command_arguments $arguments The arguments for the command * @return selfserve_lane|selfserve_lane_command_t * @throws Exception If the command cannot be executed + * @throws \Throwable */ public function execute(selfserve_lane_command $command, selfserve_lane_command_arguments $arguments): self { @@ -293,21 +509,37 @@ trait selfserve_lane_command_t // Validate customer number if (!is_numeric($customer_number) || (int)$customer_number <= 0) throw new \InvalidArgumentException("Invalid customer number: " . $customer_number); if (!(new users_o())->getUserByCustomerNumber((int)$customer_number)->exists()) throw new \InvalidArgumentException("Customer number does not exist: " . $customer_number); + $previous_customer_number = $this->getCustomerNumber(); + $previous_license_plate = $this->getLicensePlate(); // Set the customer number and license plate $this->setCustomerNumber($customer_number); $this->setLicensePlate($license_plate); + try { + // Open the entrance port before marking the lane occupied. Gateway timeouts are + // ambiguous because the relay may already have received the pulse. + $this->openEntrancePortForWashStart(); + $this->turnOnCleanerRelayForWashStart(); + } catch (\Throwable $e) { + $this->setCustomerNumber($previous_customer_number); + $this->setLicensePlate($previous_license_plate); + $this->setLaneState(selfserve_lane_state::IDLE); + throw $e; + } // Set the lane status to OCCUPIED when started $this->setLaneStatus(selfserve_lane_status::OCCUPIED); // Set the lane state to IN_WASH $this->setLaneState(selfserve_lane_state::IN_WASH); - // Open the entrance port - $this->open(selfserve_lane_port::ENTRANCE); // Start the wash timer $this->setWashStartTime(time()); - // Ensure cleaner relay is enabled whenever wash starts. - $this->turnOnCleanerRelayForWashStart(); - // Ensure the machine relay is ON when a wash starts, when it is allowed. - $this->setMachineRelayStatusForWashStart(); + $this->runPublishedStudioActions( + selfserve_studio_actions::EVENT_WASH_START_COMMAND, + $this->resolveSelfServeActionWashModeForStart(), + [ + 'customer_number' => (int)$customer_number, + 'reg' => $license_plate, + ] + ); + $this->runRelaySideEffectsForWashStart($arguments); // Log the lane start event $this->logLaneAction(selfserve_lane_log_action::START_WASH); break; @@ -318,18 +550,28 @@ trait selfserve_lane_command_t if (($this->getCustomerNumber() !== $arguments->customer_number) && !$this->isBypassCustomerNumberValidation()) { throw new \InvalidArgumentException("Customer number mismatch: Lane customer number " . $this->getCustomerNumber() . " does not match argument customer number " . $arguments->customer_number); } - // Snapshot selector relay online state before relay shutdown. - $program_selector_online = $this->isProgramSelectorRelayOnlineForStop(); - // Open the exit port - $this->open(selfserve_lane_port::EXIT); + // Snapshot the physical machine ON signal before session completion/reset. + $machine_start_triggered = $this->hasMachineStartSignalForStop(); + $this->runPublishedStudioActions( + selfserve_studio_actions::EVENT_WASH_STOP_COMMAND, + $machine_start_triggered ? selfserve_studio_actions::MODE_MACHINE : selfserve_studio_actions::MODE_MANUAL, + [ + 'customer_number' => $arguments->customer_number, + 'reg' => $this->getLicensePlate(), + 'machine_start_triggered' => $machine_start_triggered, + ] + ); + // Open the exit port. Gateway timeouts are ambiguous because + // the relay may already have received the pulse. + $this->openExitPortForWashStop(); // Turn off relays in deterministic order after STOP $this->turnOffRelaysAfterStop(); // Log the lane stop event $this->logLaneAction(selfserve_lane_log_action::STOP_WASH); // Invoice the customer - $this->invoice(); - // If program selector relay is online at stop time, bill the primary product. - $this->addVehicleTypeProductToInvoiceIfNeeded($program_selector_online); + $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 $this->completeLatestSessionForStop(); // Reset the lane diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php index 76258df3..28f0b7b1 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php @@ -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,12 @@ 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 = (new orders_o())->add( - $this->getCustomerNumber(), + $billing_customer_number, self::INVOICE_SYSTEM_USER_ID, '', '', @@ -224,10 +233,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( diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_port_controller_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_port_controller_t.php index 84430abc..96b2ebe1 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_port_controller_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_port_controller_t.php @@ -4,6 +4,7 @@ namespace modules\selfserve\traits; require_once WD . '/modules/selfserve/helpers/selfserve_lane_port.php'; require_once WD . '/modules/selfserve/classes/selfserve_lane.php'; +use classes\edge_gateway_manager; use classes\shelly_transport_resolver; use interfaces\shelly_transport_i; use modules\selfserve\helpers\selfserve_lane_port; @@ -78,8 +79,27 @@ trait selfserve_lane_port_controller_t $device = $this->createShellySwitchDevice(); $device->id = (string)$relay_id; $device->toggle_after = $this->normalizePortOpenToggleAfter($toggle_after_seconds); - $device->switch(true); - return true; + $contextPayload = [ + 'id' => (string)$relay_id, + 'on' => true, + 'toggle_after' => $device->toggle_after, + ]; + $context = method_exists($this, 'buildSelfServeRelayActionContext') + ? $this->buildSelfServeRelayActionContext('/v2/devices/api/set/switch', $contextPayload, [ + 'reason' => 'Open self-serve ' . $port->name . ' gate relay', + 'relay_role' => $port->name, + ]) + : [ + 'module' => 'selfserve', + 'reason' => 'Open self-serve ' . $port->name . ' gate relay', + 'relay_id' => (string)$relay_id, + 'relay_role' => $port->name, + ]; + + return edge_gateway_manager::withRelayActionContext($context, function () use ($device): bool { + $device->switch(true); + return true; + }); } private function normalizePortOpenToggleAfter(?int $toggle_after_seconds): int diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php index ee81fd43..362135a9 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php @@ -4,6 +4,7 @@ namespace modules\selfserve\traits; require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php'; require_once WD . '/modules/selfserve/classes/selfserve_lane.php'; +use classes\edge_gateway_manager; use classes\shelly; use classes\shelly_transport_resolver; use interfaces\shelly_transport_i; @@ -123,7 +124,7 @@ trait selfserve_lane_relay_controller_t */ public function setMachineRelayStatusHard(bool $on): bool { - return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, $on); + return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on); } /** @@ -259,6 +260,33 @@ trait selfserve_lane_relay_controller_t ]; } + /** + * Persist the services currently visible to the user without mutating hardware. + * + * The user wash start flow calls this before the user confirms lane and wash type. + * Hardware activation remains owned by START / explicit relay endpoints. + * + * @param array $allowedServices + * @return array{ + * machine_visible: bool, + * relay_action: string, + * relay_target_on: bool + * } + */ + public function setAllowedServicesFromVisibleTasks(array $allowedServices): array + { + $normalizedServices = $this->normalizeVisibleServiceNames($allowedServices); + $this->setLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES, $normalizedServices); + + $machineVisible = in_array(selfserve_lane_services::MACHINE->name, $normalizedServices, true); + + return [ + 'machine_visible' => $machineVisible, + 'relay_action' => 'cache_only', + 'relay_target_on' => $machineVisible, + ]; + } + /** * @param array $services * @return string[] @@ -720,7 +748,10 @@ trait selfserve_lane_relay_controller_t $transport = $this->createShellyTransport(); $transport->requireModuleEnabled(); $transport->requireValidSecretKey(); - return $transport->sendPostRequest($endpoint, $payload, $this->resolveShellyTransportDepartmentId()); + return edge_gateway_manager::withRelayActionContext( + $this->buildSelfServeRelayActionContext($endpoint, $payload), + fn(): array|object|null => $transport->sendPostRequest($endpoint, $payload, $this->resolveShellyTransportDepartmentId()) + ); } protected function createShellyClient(): shelly @@ -753,6 +784,99 @@ trait selfserve_lane_relay_controller_t return $department_id; } + protected function buildSelfServeRelayActionContext(string $endpoint, array $payload, array $overrides = []): array + { + $relayIds = $this->extractSelfServeRelayIdsFromShellyPayload($payload); + $context = [ + 'module' => 'selfserve', + 'reason' => $this->describeSelfServeRelayReason($endpoint, $payload), + 'lane_id' => (int)$this->id, + 'department_id' => $this->safeSelfServeDepartmentId(), + 'route' => $_SERVER['REQUEST_URI'] ?? null, + 'relay_ids' => $relayIds, + ]; + + if (count($relayIds) === 1) { + $context['relay_id'] = $relayIds[0]; + $context['relay_role'] = $this->describeSelfServeRelayRole($relayIds[0]); + } + + try { + $customerNumber = method_exists($this, 'getCustomerNumber') ? $this->getCustomerNumber() : null; + if ($customerNumber !== null && (int)$customerNumber > 0) { + $context['customer_number'] = (int)$customerNumber; + } + } catch (\Throwable) { + } + + try { + $licensePlate = method_exists($this, 'getLicensePlate') ? trim((string)$this->getLicensePlate()) : ''; + if ($licensePlate !== '') { + $context['license_plate'] = $licensePlate; + } + } catch (\Throwable) { + } + + return array_replace_recursive(array_filter( + $context, + static fn(mixed $value): bool => $value !== null && $value !== '' && $value !== [] + ), $overrides); + } + + /** + * @return string[] + */ + private function extractSelfServeRelayIdsFromShellyPayload(array $payload): array + { + $ids = []; + foreach ((array)($payload['ids'] ?? []) as $id) { + $id = trim((string)$id); + if ($id !== '') { + $ids[] = $id; + } + } + + $singleId = trim((string)($payload['id'] ?? $payload['relayId'] ?? $payload['relay_id'] ?? '')); + if ($singleId !== '') { + $ids[] = $singleId; + } + + return array_values(array_unique($ids)); + } + + private function describeSelfServeRelayReason(string $endpoint, array $payload): string + { + if (str_contains(strtolower($endpoint), '/get')) { + return 'Read self-serve relay status'; + } + + $target = array_key_exists('on', $payload) && (bool)$payload['on'] ? 'ON' : 'OFF'; + return 'Set self-serve relay ' . $target; + } + + private function describeSelfServeRelayRole(string $relayId): ?string + { + foreach (selfserve_lane_relay::cases() as $relay) { + try { + if ($this->getRelayId($relay) === $relayId) { + return $relay->name; + } + } catch (\Throwable) { + } + } + + return null; + } + + private function safeSelfServeDepartmentId(): ?int + { + try { + return $this->resolveShellyTransportDepartmentId(); + } catch (\Throwable) { + return null; + } + } + protected function redisFacade(): mixed { return defined('redis') ? redis : null; diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_status_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_status_t.php index 06186b09..3a5a0913 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_status_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_status_t.php @@ -87,4 +87,44 @@ trait selfserve_lane_status_t return $this; } -} \ No newline at end of file + + 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, + ]; + } +} diff --git a/services/nginx/app/modules/washcertificates/composer.json b/services/nginx/app/modules/washcertificates/composer.json index 628892b6..63add6e9 100644 --- a/services/nginx/app/modules/washcertificates/composer.json +++ b/services/nginx/app/modules/washcertificates/composer.json @@ -19,5 +19,8 @@ "setasign/fpdi": "^2.6", "setasign/fpdf": "^1.8", "ext-mysqli": "*" + }, + "config": { + "secure-http": false } } diff --git a/services/nginx/app/modules/washcertificates/composer.lock b/services/nginx/app/modules/washcertificates/composer.lock index 944fc255..8dcbfd89 100644 --- a/services/nginx/app/modules/washcertificates/composer.lock +++ b/services/nginx/app/modules/washcertificates/composer.lock @@ -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" } diff --git a/services/nginx/app/modules/washcertificates/index.php b/services/nginx/app/modules/washcertificates/index.php index 9563d96c..49c026a4 100644 --- a/services/nginx/app/modules/washcertificates/index.php +++ b/services/nginx/app/modules/washcertificates/index.php @@ -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'); @@ -79,6 +79,10 @@ if (isset($_GET['justDownload'])) { exit; } +http_response_code(410); +echo 'Booking completion must be completed through POS desktop or mobile steps.'; +exit; + // Require the $_GET variables sealOrPlumber, safetySeal, performedBy, and bookingId, regNumber, and regNumberTrailer to be set if (!isset($_GET['sealOrPlumber']) || !isset($_GET['performedBy']) || !isset($_GET['bookingId']) || !isset($_GET['regNumber']) || !isset($_GET['regNumberTrailer']) || !isset($_GET['department'])) { // We are missing some required fields in the query string @@ -140,4 +144,4 @@ $booking->washCertificateStatus->set('completed'); // Return the generated certificate object download URL echo $wash_certificate_store->getWashCertificateDownload($_GET['bookingId']); // Exit the script -exit; \ No newline at end of file +exit; diff --git a/services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php b/services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php new file mode 100644 index 00000000..bcd47458 --- /dev/null +++ b/services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php @@ -0,0 +1,29 @@ +importCustomers(); (new xlvask_vehicles_o())->importVehicles(); (new xlvask_usage_logs_o())->importUsageLogs(); + (new xlvask_automation_service())->runPending(null, null, [], 100, null); }; } @@ -608,4 +612,4 @@ class xlvask_tasks $xlvask->requireModuleEnabled(); // Run } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/xlvask/helpers/xlvask_usage_log.php b/services/nginx/app/modules/xlvask/helpers/xlvask_usage_log.php index 8f06f093..f827a17c 100644 --- a/services/nginx/app/modules/xlvask/helpers/xlvask_usage_log.php +++ b/services/nginx/app/modules/xlvask/helpers/xlvask_usage_log.php @@ -141,6 +141,21 @@ class xlvask_usage_log extends xlvask_helper * @see xlvask_wash_item */ public array $WashItems; + /** + * Timestamp for invoice-period ignore state, when the wash has been ignored by a superuser. + * @var string|int|null $ignored_at + */ + public string|int|null $ignored_at; + /** + * Superuser id for invoice-period ignore state. + * @var int|string|null $ignored_by + */ + public int|string|null $ignored_by; + /** + * Optional reason for invoice-period ignore state. + * @var string|int|null $ignored_reason + */ + public string|int|null $ignored_reason; private string $default_string = 'DEFAULT_STRING_1'; private string $default_int = 'DEFAULT_INT_1'; @@ -194,6 +209,9 @@ class xlvask_usage_log extends xlvask_helper $this->CustomerGuid = $this->default_string; $this->VehicleId = $this->default_string; $this->WashItems = []; // Initialize as an empty array + $this->ignored_at = $this->default_string_nullable; + $this->ignored_by = $this->default_int_nullable; + $this->ignored_reason = $this->default_string_nullable; } /** @@ -226,6 +244,9 @@ class xlvask_usage_log extends xlvask_helper 'FinishStatus' => $this->default_int, 'CustomerGuid' => $this->default_string, 'VehicleId' => $this->default_string, + 'ignored_at' => $this->default_string_nullable, + 'ignored_by' => $this->default_int_nullable, + 'ignored_reason' => $this->default_string_nullable, ]; foreach ( $data as $key => $value ) { if (property_exists(self::class, $key)) { @@ -363,7 +384,8 @@ class xlvask_usage_log extends xlvask_helper 'WashId', 'CustomerId', 'Customer', 'VatNumber', 'Location', 'Hall', 'HallId', 'StartTime', 'FinishTime', 'RegistrationNumber', 'VehicleType', 'IdentificationType', 'IdentificationId', 'Info', - 'Updated', 'Prepaid', 'FinishStatus', 'CustomerGuid', 'VehicleId' + 'Updated', 'Prepaid', 'FinishStatus', 'CustomerGuid', 'VehicleId', + 'ignored_at', 'ignored_by', 'ignored_reason', ]; foreach ( $properties as $property ) { if ($this->isEmptyOrDefault($this->{$property})) { @@ -639,4 +661,4 @@ class xlvask_usage_log extends xlvask_helper // Check if the wash is prepaid return !empty($this->Prepaid) && $this->Prepaid === 1; // Assuming 1 indicates a prepaid wash } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/xlvask/xlvask_c.php b/services/nginx/app/modules/xlvask/xlvask_c.php index 69560070..016334b8 100644 --- a/services/nginx/app/modules/xlvask/xlvask_c.php +++ b/services/nginx/app/modules/xlvask/xlvask_c.php @@ -3,11 +3,17 @@ namespace xlvask; require_once WD . '/modules/xlvask/config/xlvask_enabled_c.php'; require_once WD . '/modules/xlvask/config/xlvask_synchronization_enabled_c.php'; +require_once WD . '/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php'; +require_once WD . '/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php'; +require_once WD . '/modules/xlvask/config/xlvask_openai_integration_enabled_c.php'; require_once WD . '/modules/xlvask/config/xlvask_username_c.php'; require_once WD . '/modules/xlvask/config/xlvask_password_c.php'; use traits\module_config_t; +use xlvask\config\xlvask_automatic_order_attachment_enabled_c; +use xlvask\config\xlvask_automatic_order_creation_enabled_c; use xlvask\config\xlvask_enabled_c; +use xlvask\config\xlvask_openai_integration_enabled_c; use xlvask\config\xlvask_password_c; use xlvask\config\xlvask_synchronization_enabled_c; use xlvask\config\xlvask_username_c; @@ -31,6 +37,18 @@ class xlvask_c * @var xlvask_synchronization_enabled_c $synchronization_enabled */ public xlvask_synchronization_enabled_c $synchronization_enabled; + /** + * @var xlvask_automatic_order_attachment_enabled_c $automatic_order_attachment_enabled + */ + public xlvask_automatic_order_attachment_enabled_c $automatic_order_attachment_enabled; + /** + * @var xlvask_automatic_order_creation_enabled_c $automatic_order_creation_enabled + */ + public xlvask_automatic_order_creation_enabled_c $automatic_order_creation_enabled; + /** + * @var xlvask_openai_integration_enabled_c $openai_integration_enabled + */ + public xlvask_openai_integration_enabled_c $openai_integration_enabled; /** * The username * @var xlvask_username_c @@ -53,12 +71,18 @@ class xlvask_c $this->allowUpdate([ xlvask_enabled_c::class, xlvask_synchronization_enabled_c::class, + xlvask_automatic_order_attachment_enabled_c::class, + xlvask_automatic_order_creation_enabled_c::class, + xlvask_openai_integration_enabled_c::class, xlvask_username_c::class, xlvask_password_c::class ]); $this->enabled = new xlvask_enabled_c(); $this->synchronization_enabled = new xlvask_synchronization_enabled_c(); + $this->automatic_order_attachment_enabled = new xlvask_automatic_order_attachment_enabled_c(); + $this->automatic_order_creation_enabled = new xlvask_automatic_order_creation_enabled_c(); + $this->openai_integration_enabled = new xlvask_openai_integration_enabled_c(); $this->username = new xlvask_username_c(); $this->password = new xlvask_password_c(); } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/collected_order_invoices_o.php b/services/nginx/app/objects/collected_order_invoices_o.php index 1411b6fe..cac02b97 100644 --- a/services/nginx/app/objects/collected_order_invoices_o.php +++ b/services/nginx/app/objects/collected_order_invoices_o.php @@ -508,6 +508,7 @@ class collected_order_invoices_o extends db } // Set the processor to E-conomic, if it's not already set to Stripe. $this->processor->set(ECONOMIC_PROCESSOR); + $this->error_message->nullify(); // Object changed self::objectChanged(); return $this; @@ -1032,6 +1033,207 @@ class collected_order_invoices_o extends db $this->objectChanged(); } + /** + * Split this invoice collection into one collection per order month. + * + * @return array + * @throws Exception + */ + public function splitByOrderMonth(): array + { + global $db; + + self::requireSelected(); + $this->requireCanSplitByOrderMonth(); + + $orders_by_month = $this->getIncludedOrdersGroupedByCreatedMonth(); + $preview = $this->buildSplitByOrderMonthPreview($orders_by_month); + if (($preview['status'] ?? '') === 'skipped') { + $preview['preview'] = false; + return $preview; + } + + $original_invoice_collection_id = (int)$this->id; + $created_invoice_collection_ids = []; + $month_collection_ids = []; + $months = array_keys($orders_by_month); + $month_results = $preview['months']; + + $db->conn()->begin_transaction(); + try { + foreach ( $months as $index => $month ) { + $month_timestamp = self::getFirstDayOfMonth($month . '-01 00:00:01'); + $month_closed_at = self::getLastSecondOfMonthIfEnded($month . '-01 00:00:01'); + if ($index === 0) { + $month_collection = $this; + $month_collection->created_at->set($month_timestamp); + $month_collection->closed_at->set($month_closed_at); + } else { + $month_collection = (new collected_order_invoices_o())->add( + (int)$this->customer_number->value(), + $this->name->value(), + $this->notes->value(), + null, + $month_closed_at + ); + $month_collection->created_at->set($month_timestamp); + $created_invoice_collection_ids[] = (int)$month_collection->id; + } + + $month_collection_ids[$month] = (int)$month_collection->id; + $month_results[$index]['invoice_collection_id'] = (int)$month_collection->id; + $month_results[$index]['target_invoice_collection_id'] = (int)$month_collection->id; + } + + foreach ( $orders_by_month as $month => $orders ) { + $target_invoice_collection_id = (int)$month_collection_ids[$month]; + foreach ( $orders as $order ) { + if ((int)$order->invoice_collection_id->value() === $target_invoice_collection_id) { + continue; + } + $order->assignToInvoiceCollection($target_invoice_collection_id); + } + } + + $this->objectChanged(); + foreach ( $created_invoice_collection_ids as $created_invoice_collection_id ) { + (new collected_order_invoices_o())->select($created_invoice_collection_id)->objectChanged(); + } + + $db->conn()->commit(); + } catch (\Throwable $e) { + $db->conn()->rollback(); + throw $e; + } + + return [ + 'status' => 'changed', + 'invoice_collection_id' => $original_invoice_collection_id, + 'preview' => false, + 'created_invoice_collection_ids' => $created_invoice_collection_ids, + 'months' => $month_results, + ]; + } + + /** + * Preview how this invoice collection would be split into one collection per order month. + * + * @return array + * @throws Exception + */ + public function previewSplitByOrderMonth(): array + { + self::requireSelected(); + $this->requireCanSplitByOrderMonth(); + + return $this->buildSplitByOrderMonthPreview($this->getIncludedOrdersGroupedByCreatedMonth()); + } + + /** + * @param array $orders_by_month + * @return array + * @throws Exception + */ + private function buildSplitByOrderMonthPreview(array $orders_by_month): array + { + if (empty($orders_by_month)) { + throw new Exception('No orders in invoice collection'); + } + + ksort($orders_by_month); + if (count($orders_by_month) < 2) { + return [ + 'status' => 'skipped', + 'reason' => 'already_single_month', + 'message' => 'Invoice collection already belongs to one month', + 'invoice_collection_id' => (int)$this->id, + 'preview' => true, + 'months' => array_keys($orders_by_month), + ]; + } + + $months = []; + foreach ( array_keys($orders_by_month) as $index => $month ) { + $order_ids = array_map(static function (orders_o $order): int { + return (int)$order->id; + }, $orders_by_month[$month]); + $month_timestamp = self::getFirstDayOfMonth($month . '-01 00:00:01'); + $month_closed_at = self::getLastSecondOfMonthIfEnded($month . '-01 00:00:01'); + $will_create_collection = $index !== 0; + $months[] = [ + 'month' => $month, + 'invoice_collection_id' => $will_create_collection ? null : (int)$this->id, + 'target_invoice_collection_id' => $will_create_collection ? null : (int)$this->id, + 'source_invoice_collection_id' => (int)$this->id, + 'will_create_collection' => $will_create_collection, + 'order_count' => count($orders_by_month[$month]), + 'order_ids' => $order_ids, + 'created_at' => $month_timestamp, + 'closed_at' => $month_closed_at, + ]; + } + + return [ + 'status' => 'changed', + 'invoice_collection_id' => (int)$this->id, + 'preview' => true, + 'created_invoice_collection_ids' => [], + 'months' => $months, + ]; + } + + /** + * @throws Exception + */ + private function requireCanSplitByOrderMonth(): void + { + self::requireSelected(); + self::requireInvoiceIsNotBooked(); + + $processor = $this->processor->value(); + $processor = $processor === null ? 0 : (int)$processor; + if ($processor === STRIPE_PROCESSOR) { + throw new Exception('Stripe invoice collections cannot be split'); + } + if ($processor === OTHER_PROCESSOR) { + throw new Exception('Due to the stateless nature of the processor, invoice collections cannot be split. Please contact technical support for assistance.'); + } + if (!in_array($processor, [0, ECONOMIC_PROCESSOR], true)) { + throw new Exception('Invalid processor type'); + } + if (!empty($this->external_id->value())) { + throw new Exception('Invoice collection already has an external invoice reference'); + } + } + + /** + * @return array + * @throws Exception + */ + private function getIncludedOrdersGroupedByCreatedMonth(): array + { + $order_ids = self::getOrderIds(); + $orders_by_month = []; + foreach ( $order_ids as $order_id ) { + $order = (new orders_o())->select((int)$order_id['id']); + $order->requireSelected(); + if ($order->isBooked(true)) { + throw new Exception('Invoice collection contains booked orders'); + } + + $created_at = (string)$order->created_at->value(); + if (strtotime($created_at) === false) { + throw new Exception('Order has invalid created_at date'); + } + + $month = date('Y-m', strtotime($created_at)); + $orders_by_month[$month] = $orders_by_month[$month] ?? []; + $orders_by_month[$month][] = $order; + } + + return $orders_by_month; + } + /** * Add the vehicle subscriptions transaction to the invoice collection * @throws Exception If the invoice collection is not selected @@ -1254,6 +1456,18 @@ class collected_order_invoices_o extends db return $date->format('Y-m-d H:i:s'); } + private static function getLastSecondOfMonthIfEnded(string $timestamp): ?string + { + $date = new \DateTime($timestamp); + $date->modify('last day of this month'); + $date->setTime(23, 59, 59); + if ($date > new \DateTime()) { + return null; + } + + return $date->format('Y-m-d H:i:s'); + } + /** * Get the wash subscription price * @param float $price The price of the wash subscription diff --git a/services/nginx/app/objects/department_gates_o.php b/services/nginx/app/objects/department_gates_o.php index 3a0f21dd..ee107ee0 100644 --- a/services/nginx/app/objects/department_gates_o.php +++ b/services/nginx/app/objects/department_gates_o.php @@ -432,11 +432,25 @@ class department_gates_o extends db $pulseSeconds = isset($config['pulse_seconds']) ? max(0, (int)$config['pulse_seconds']) : 1; $manager = $this->resolveEdgeGatewayManager(); - $manager->dispatchRelaySwitch($departmentId, $relayId, true); + $manager->dispatchRelaySwitch($departmentId, $relayId, true, [ + 'module' => 'department_gates', + 'reason' => 'Open relay-backed department gate', + 'gate_id' => (int)$this->id, + 'gate_name' => (string)$this->name->value(), + 'relay_id' => $relayId, + 'pulse_seconds' => $pulseSeconds, + ]); if ($pulseSeconds > 0) { usleep($pulseSeconds * 1000000); - $manager->dispatchRelaySwitch($departmentId, $relayId, false); + $manager->dispatchRelaySwitch($departmentId, $relayId, false, [ + 'module' => 'department_gates', + 'reason' => 'Close relay-backed department gate after pulse', + 'gate_id' => (int)$this->id, + 'gate_name' => (string)$this->name->value(), + 'relay_id' => $relayId, + 'pulse_seconds' => $pulseSeconds, + ]); } } } diff --git a/services/nginx/app/objects/department_lanes_o.php b/services/nginx/app/objects/department_lanes_o.php index 5b3db847..eb371258 100644 --- a/services/nginx/app/objects/department_lanes_o.php +++ b/services/nginx/app/objects/department_lanes_o.php @@ -22,6 +22,7 @@ class department_lanes_o extends db public object_property $relay_machine_cleaner_id; // The Shelly relay for the machine cleaner (if applicable) public object_property $dynamic_image_id; // The dynamic image id for the lane (if applicable) public object_property $machine_type_id; // The reusable self-serve machine type for the lane (if applicable) + public object_property $selfserve_enabled; // Whether this lane can be used for self-serve when department self-serve is enabled public object_property $created_at; public object_property $updated_at; public object_property $deleted_at; @@ -52,7 +53,7 @@ class department_lanes_o extends db * @return department_lanes_o * @throws Exception If the object was not created successfully */ - public function add(int $department, string $name, string $relay_in_id = null, string $relay_out_id = null, string $relay_machine_id = null, string $relay_machine_program_picker_id = null, string $relay_machine_cleaner_id = null, int $dynamic_image_id = null, ?int $machine_type_id = null): department_lanes_o + public function add(int $department, string $name, string $relay_in_id = null, string $relay_out_id = null, string $relay_machine_id = null, string $relay_machine_program_picker_id = null, string $relay_machine_cleaner_id = null, int $dynamic_image_id = null, ?int $machine_type_id = null, bool $selfserve_enabled = true): department_lanes_o { global /** @var db $db */ $db; @@ -97,6 +98,7 @@ class department_lanes_o extends db ...(!is_null($relay_machine_cleaner_id) ? ['relay_machine_cleaner_id' => $relay_machine_cleaner_id] : []), ...(!is_null($dynamic_image_id) ? ['dynamic_image_id' => $dynamic_image_id] : []), // If the dynamic_image_id is null, it will be set to null in the database ...(!is_null($machine_type_id) ? ['machine_type_id' => $machine_type_id] : []), + 'selfserve_enabled' => $selfserve_enabled ? 1 : 0, ]); $this->id = $tmp_id; self::getObjectProperties(); @@ -115,6 +117,7 @@ class department_lanes_o extends db $this->relay_machine_cleaner_id = new object_property($this->table, $this->id, 'relay_machine_cleaner_id', 'string', false); $this->dynamic_image_id = new object_property($this->table, $this->id, 'dynamic_image_id', 'int', false); $this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false); + $this->selfserve_enabled = new object_property($this->table, $this->id, 'selfserve_enabled', 'bool', false, true); $this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false); $this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false); $this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false); @@ -127,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(), @@ -138,14 +145,177 @@ class department_lanes_o extends db 'relay_machine_cleaner_id' => (string)$this->relay_machine_cleaner_id->value(), 'dynamic_image_id' => (function($v){ return $v === null ? null : (int)$v; })($this->dynamic_image_id->value()), '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(); + try { + $value = $this->selfserve_enabled->value(); + } catch (\Throwable) { + return true; + } + + if ($value === null || $value === '') { + return true; + } + if (is_bool($value)) { + return $value; + } + if (is_numeric($value)) { + return (int)$value === 1; + } + + return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true); + } + + public static function normalizeSelfServeEnabledValue(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + if (is_numeric($value)) { + return (int)$value === 1; + } + + 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) { + return; + } + + try { + $lane = (new selfserve())->lane($lane_id); + } catch (\Throwable) { + return; + } + + self::setLaneRelayOffIfConfigured($lane, 'relay_machine_program_picker_id', static function () use ($lane): void { + $lane->setMachineProgramPickerRelayStatusHard(false); + }); + self::setLaneRelayOffIfConfigured($lane, 'relay_machine_cleaner_id', static function () use ($lane): void { + $lane->setMachineCleanerRelayStatusHard(false); + }); + self::setLaneRelayOffIfConfigured($lane, 'relay_machine_id', static function () use ($lane): void { + $lane->setMachineRelayStatusHard(false); + }); + } + + private static function setLaneRelayOffIfConfigured(object $lane, string $relay_property, callable $callback): void + { + if ( + empty($lane->department_lane) + || !isset($lane->department_lane->{$relay_property}) + || !is_object($lane->department_lane->{$relay_property}) + || !method_exists($lane->department_lane->{$relay_property}, 'value') + || trim((string)$lane->department_lane->{$relay_property}->value()) === '' + ) { + return; + } + + try { + $callback(); + } catch (\Throwable) { + // Best effort only; toggling lane self-serve should not fail on relay I/O. + } + } + /** * Get the self-serve lane products available for this lane * @return array An array of product ids available for this lane diff --git a/services/nginx/app/objects/department_selfserve_tasks_o.php b/services/nginx/app/objects/department_selfserve_tasks_o.php index 16f0fb8b..3e077698 100644 --- a/services/nginx/app/objects/department_selfserve_tasks_o.php +++ b/services/nginx/app/objects/department_selfserve_tasks_o.php @@ -33,7 +33,7 @@ class department_selfserve_tasks_o extends db public object_property $description; // The task description public object_property $order_priority; // The order priority of the task (lower numbers are shown first) public object_property $services; // The services that the task enables (json), this is used to enable machine wash. - public object_property $buttons; // The buttons on the departments machine dynamic image that should be enabled by this task (json array of button ids) + public object_property $buttons; // The buttons on the departments machine dynamic image that should be enabled by this task (json array of mapped button ids) public object_property $dynamic_images_vehicle_type; // The vehicle type selection override on the machine, used by dynamicimages - int or null if not applicable. public object_property $created_at; public object_property $updated_at; @@ -72,7 +72,7 @@ class department_selfserve_tasks_o extends db * @param string $description The task description * @param int $order_priority The order priority of the task (lower numbers are shown first) * @param selfserve_lane_services[]|string[]|null $services The services that the task enables (stored as JSON array of service names). May be an array of enum cases or names. - * @param array|string|null $buttons Optional buttons on the department's machine dynamic image to be enabled by this task (stored as JSON array of button IDs). Accepts array of ints or a parsable string/JSON. + * @param array|string|null $buttons Optional buttons on the department's machine dynamic image to be enabled by this task (stored as JSON array of button IDs). Accepts program integers plus "reset" and "start". * @param int|null $dynamic_images_vehicle_type Optional vehicle type selection override for the machine UI. Integer >= 0 or null. * @return department_selfserve_tasks_o * @throws Exception If the object was not created successfully @@ -358,13 +358,13 @@ class department_selfserve_tasks_o extends db return $val; } /** - * Normalize mixed input for buttons into an array of integer IDs (>= 0). + * Normalize mixed input for buttons into an array of mapped button IDs. * Accepts: * - array of ints/strings * - JSON array string * - comma-separated string * @param mixed $input - * @return array + * @return array * @throws Exception */ public static function normalizeButtonsInput(mixed $input): array @@ -379,14 +379,22 @@ class department_selfserve_tasks_o extends db } } if (!is_array($raw)) { - throw new Exception('Invalid format for buttons. Expected array, JSON array, or comma-separated string of integers.'); + throw new Exception('Invalid format for buttons. Expected array, JSON array, or comma-separated string of mapped button ids.'); } $ids = []; foreach ($raw as $btn) { + if (is_string($btn)) { + $trimmed = trim($btn); + $specialButton = strtolower($trimmed); + if ($specialButton === 'reset' || $specialButton === 'start' || $specialButton === 'program_picker') { + $ids[] = $specialButton; + continue; + } + } if (is_int($btn)) { $val = $btn; - } elseif (is_string($btn) && ctype_digit($btn)) { - $val = (int)$btn; + } elseif (is_string($btn) && ctype_digit(trim($btn))) { + $val = (int)trim($btn); } elseif (is_numeric($btn) && (int)$btn == $btn) { $val = (int)$btn; } else { @@ -398,7 +406,16 @@ class department_selfserve_tasks_o extends db $ids[] = $val; } // de-duplicate while preserving order - $ids = array_values(array_unique($ids)); - return $ids; + $deduped = []; + $seen = []; + foreach ($ids as $id) { + $key = (is_int($id) ? 'int:' : 'string:') . (string)$id; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $deduped[] = $id; + } + return $deduped; } } diff --git a/services/nginx/app/objects/department_selfserve_vehicle_conditions_o.php b/services/nginx/app/objects/department_selfserve_vehicle_conditions_o.php index d1677123..574fab23 100644 --- a/services/nginx/app/objects/department_selfserve_vehicle_conditions_o.php +++ b/services/nginx/app/objects/department_selfserve_vehicle_conditions_o.php @@ -53,8 +53,10 @@ class department_selfserve_vehicle_conditions_o extends db $question = (int)$question; $value = (bool)$value; $customer_id = $customer_id !== null ? (int)$customer_id : null; - // Remove any existing entry for the same department, lane, reg and question - $sql = "DELETE FROM $this->table WHERE department = $department AND lane = $lane AND reg = '$reg' AND question = $question"; + // Remove any existing entry for the same department, lane, customer, reg and question. + // Saved answers must not bleed across customers that temporarily wash the same plate. + $customer_filter = $customer_id === null ? 'customer_id IS NULL' : 'customer_id = ' . $customer_id; + $sql = "DELETE FROM $this->table WHERE department = $department AND lane = $lane AND $customer_filter AND reg = '$reg' AND question = $question"; $db->query($sql); // Add the object $tmp_id = self::add_object([ @@ -105,11 +107,16 @@ class department_selfserve_vehicle_conditions_o extends db ]; } - public function getAnswerMapForVehicle(int $departmentId, int $laneId, string $reg): array + public function getAnswerMapForVehicle(int $departmentId, int $laneId, string $reg, ?int $customerId = null): array { + if ($customerId === null || $customerId <= 0) { + return []; + } + $rows = self::getFieldsWhere([ 'department' => $departmentId, 'lane' => $laneId, + 'customer_id' => $customerId, 'reg' => selfserve::standardize_registration($reg), 'deleted_at' => null, ], ['question', 'value']); diff --git a/services/nginx/app/objects/departments_o.php b/services/nginx/app/objects/departments_o.php index 9dc3f979..9baaea50 100644 --- a/services/nginx/app/objects/departments_o.php +++ b/services/nginx/app/objects/departments_o.php @@ -3,6 +3,7 @@ namespace objects; use classes\db; +use classes\departments_schema_bootstrap; use classes\object_property; use classes\slack; use classes\stripe; @@ -20,6 +21,7 @@ class departments_o extends db public department_variables_o $variables; // The department variables object public object_property $dimension; // The dimension of the department public object_property $visible; // The visibility of the department + public object_property $archived; // Whether the department is archived public object_property $branding; // The branding of the department public object_property $longitude; // The longitude of the department (Can be null) public object_property $latitude; // The latitude of the department (Can be null) @@ -29,6 +31,7 @@ class departments_o extends db public function structure(): void { + departments_schema_bootstrap::ensureTables(); $this->setTable('departments'); } @@ -103,6 +106,7 @@ class departments_o extends db $this->dimension = new object_property($this->table, $this->id, 'dimension', 'int', false); $this->branding = new object_property($this->table, $this->id, 'branding', 'int', false); $this->visible = new object_property($this->table, $this->id, 'visible', 'int', false); + $this->archived = new object_property($this->table, $this->id, 'archived', 'boolean', false); $this->longitude = new object_property($this->table, $this->id, 'longitude', 'float', false); $this->latitude = new object_property($this->table, $this->id, 'latitude', 'float', false); $this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false); @@ -156,6 +160,7 @@ class departments_o extends db 'description' => $department['description'], 'id' => $department['id'], 'visible' => $department['visible'], + 'archived' => $department['archived'] ?? 0, ]; }, $departments); } diff --git a/services/nginx/app/objects/order_bookings_o.php b/services/nginx/app/objects/order_bookings_o.php index b5a15ec7..8888020f 100644 --- a/services/nginx/app/objects/order_bookings_o.php +++ b/services/nginx/app/objects/order_bookings_o.php @@ -354,35 +354,36 @@ class order_bookings_o extends db /** * @throws Exception */ - public function completeBooking(int $user_id, string $safety_seal = null): void + public function completeBooking(int $user_id, ?string $safety_seal = null): void { self::requireSelected(); - $orderWasCreatedDuringCompletion = false; if (!$this->order_id->value()) { // Create order, if not already created $this->createOrderBy($user_id); // Add order items, re-calculate the prices to be customer-specific $this->createOrderItemsBy($user_id); - $orderWasCreatedDuringCompletion = true; - } - - if (!$this->containsWashCertificateItem()) { - return; } $order = $this->getOrder(); + if (!$this->containsWashCertificateItem() && !$order->containsWashCertificateItem()) { + return; + } + + $this->requireLinkedOrderMatchesBooking($order); $normalizedSafetySeal = orders_o::normalizeSafetySealValue($safety_seal); if ($normalizedSafetySeal !== null) { $order->setSafetySealValue($normalizedSafetySeal); $order->objectChanged(); } - if (!$orderWasCreatedDuringCompletion) { + if ($order->hasWashCertificateAttached()) { return; } $this->attachWashCertificate($user_id, $order->getSafetySealValue()); - $this->sendWashCertificateToCustomer(); + if ($order->hasWashCertificateAttached()) { + $this->sendWashCertificateToCustomer(); + } } /** @@ -414,11 +415,16 @@ class order_bookings_o extends db continue; } $orderItems = new order_items_o(); + $itemNotes = isset($item['notes']) && trim((string)$item['notes']) !== '' + ? (string)$item['notes'] + : ((string)($this->note->value() ?? '') ?: null); $orderItems->addItemToOrder( (int)$order->id, (int)$item['id'], (int)$user_id, (int)$item['quantity'], + null, + $itemNotes, ); } @@ -465,11 +471,31 @@ class order_bookings_o extends db /** * @throws Exception */ - protected function attachWashCertificate(int $user_id, string $safety_seal = null): void + private function requireLinkedOrderMatchesBooking(orders_o $order): void { self::requireSelected(); + + $bookingCustomerNumber = (int)$this->customer_number->value(); + $bookingDepartmentId = (int)$this->department->value(); + $orderCustomerId = (int)$order->customer_id->value(); + $orderDepartmentId = (int)$order->department_id->value(); + + if ($orderCustomerId !== $bookingCustomerNumber || $orderDepartmentId !== $bookingDepartmentId) { + throw new Exception('Linked order does not match booking customer or department'); + } + } + + /** + * @throws Exception + */ + protected function attachWashCertificate(int $user_id, ?string $safety_seal = null): void + { + self::requireSelected(); + $order = $this->getOrder(); + $this->requireLinkedOrderMatchesBooking($order); + // Check if the order already has a wash certificate attached - if ($this->getOrder()->hasWashCertificateAttached()) { + if ($order->hasWashCertificateAttached()) { return; } // Get the operator name diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index a3eab301..03d98546 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -154,6 +154,80 @@ class orders_o extends db // Save the object } + /** + * Return the data needed to decide whether deletion needs explicit confirmation. + * + * @return array{ + * requires_confirmation: bool, + * protected_reasons: array, + * order_item_count: int, + * attachment_count: int, + * completed_at: mixed + * } + * @throws Exception + */ + public function getDeleteProtectionSummary(): array + { + self::requireSelected(); + + $completedAt = $this->completed_at->value(); + $orderItemCount = $this->countActiveOrderItems(); + $attachmentCount = $this->countActiveOrderAttachments(); + $protectedReasons = []; + + if ($completedAt !== null) { + $protectedReasons[] = 'completed'; + } + if ($orderItemCount > 0) { + $protectedReasons[] = 'order_items'; + } + if ($attachmentCount > 0) { + $protectedReasons[] = 'attachments'; + } + + return [ + 'requires_confirmation' => count($protectedReasons) > 0, + 'protected_reasons' => $protectedReasons, + 'order_item_count' => $orderItemCount, + 'attachment_count' => $attachmentCount, + 'completed_at' => $completedAt, + ]; + } + + /** + * @throws Exception + */ + private function countActiveOrderItems(): int + { + self::requireSelected(); + global $db; + + $orderId = (int)$this->id; + $result = $db->query("SELECT COUNT(*) AS total FROM order_items WHERE order_id = {$orderId} AND deleted_at IS NULL"); + if ($result && $row = $result->fetch_assoc()) { + return (int)($row['total'] ?? 0); + } + + return 0; + } + + /** + * @throws Exception + */ + private function countActiveOrderAttachments(): int + { + self::requireSelected(); + global $db; + + $orderId = (int)$this->id; + $result = $db->query("SELECT COUNT(*) AS total FROM object_attachments WHERE object_type = 'orders' AND object_id = {$orderId} AND deleted_at IS NULL"); + if ($result && $row = $result->fetch_assoc()) { + return (int)($row['total'] ?? 0); + } + + return 0; + } + /** * @throws Exception If the order is not selected * This function is called when the order object is changed. @@ -278,6 +352,7 @@ class orders_o extends db } // Set the completed_at property to the current timestamp $this->completed_at->set(date('Y-m-d H:i:s')); + $this->setPendingHandheldIndicator(false); $washCertificateCreated = $this->completeWashCertificateIfNeeded( $operator, (string)$this->completed_at->value() @@ -1119,6 +1194,141 @@ class orders_o extends db return $transactions; } + /** + * Get period transactions as plain rows grouped by customer number. + * + * This avoids hydrating one orders_o object per order for the invoicing period response. + * + * @param int[]|null $customers Null means all local customers with orders in the period. + * @return array>> + * @throws Exception + */ + public function getPeriodTransactionsForCustomersInDateRange(?array $customers, string $dateFrom, string $dateTo): array + { + global $db; + if (strtotime($dateFrom) === false || strtotime($dateTo) === false) { + throw new Exception('Invalid date range provided'); + } + if (strtotime($dateFrom) > strtotime($dateTo)) { + throw new Exception('The start date cannot be after the end date'); + } + + $customerFilter = ''; + if ($customers !== null) { + $customers = array_values(array_unique(array_filter( + array_map('intval', $customers), + static fn(int $customerNumber): bool => $customerNumber > 0 + ))); + if (empty($customers)) { + return []; + } + $customerFilter = ' AND o.customer_id IN (' . implode(',', $customers) . ')'; + } + + $dateFrom = $db->escape_string($dateFrom); + $dateTo = $db->escape_string($dateTo); + $sql = " + SELECT + o.id, + o.customer_id AS customer_number, + customer_user.user_id, + customer_user.customer_name, + o.created_at, + COALESCE(SUM(CASE WHEN oi.include_in_invoice = 1 THEN oi.price * oi.quantity ELSE 0 END), 0) AS net_amount, + CASE + WHEN COALESCE(o.invoice_collection_id, 0) > 0 + THEN CASE WHEN COALESCE(coi.booked_invoice_id, 0) <> 0 THEN 1 ELSE 0 END + ELSE CASE WHEN COALESCE(emo.invoice_id, 0) <> 0 THEN 1 ELSE 0 END + END AS booked, + o.department_id, + o.reference, + o.po, + o.notes, + o.reg_1, + o.reg_2, + o.reg_3, + o.invoice_collection_id, + CASE + WHEN o.include_in_invoice IS NOT NULL THEN o.include_in_invoice + WHEN COALESCE(department_flags.exclude_from_invoicing, 0) = 1 THEN 0 + ELSE 1 + END AS include_in_invoice_effective + FROM {$this->table} o + INNER JOIN ( + SELECT customer_number, MIN(id) AS user_id, MAX(display_name) AS customer_name + FROM users + WHERE customer_number IS NOT NULL AND customer_number <> 0 + GROUP BY customer_number + ) customer_user ON customer_user.customer_number = o.customer_id + LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '') + LEFT JOIN collected_order_invoices coi ON coi.id = o.invoice_collection_id + LEFT JOIN economic_module_orders emo ON emo.id = o.id + LEFT JOIN ( + SELECT department_id, MAX(value = 'true') AS exclude_from_invoicing + FROM department_variables + WHERE variable = 'exclude_from_invoicing' + GROUP BY department_id + ) department_flags ON department_flags.department_id = o.department_id + WHERE o.created_at BETWEEN '{$dateFrom}' AND '{$dateTo}' + AND o.deleted_at IS NULL + {$customerFilter} + GROUP BY + o.id, + o.customer_id, + customer_user.user_id, + customer_user.customer_name, + o.created_at, + coi.booked_invoice_id, + emo.invoice_id, + o.department_id, + o.reference, + o.po, + o.notes, + o.reg_1, + o.reg_2, + o.reg_3, + o.invoice_collection_id, + o.include_in_invoice, + department_flags.exclude_from_invoicing + ORDER BY o.customer_id, o.created_at, o.id"; + $result = $db->query($sql); + if (!$result || $result->num_rows === 0) { + return []; + } + + $transactions = []; + while ($row = $result->fetch_assoc()) { + $customerNumber = (int)$row['customer_number']; + if ($customerNumber < 1) { + continue; + } + $invoiceCollectionId = (int)($row['invoice_collection_id'] ?? 0); + $transactions[$customerNumber][] = [ + 'id' => (int)$row['id'], + 'date' => (string)($row['created_at'] ?? ''), + 'created_at' => (string)($row['created_at'] ?? ''), + 'amount' => (float)($row['net_amount'] ?? 0), + 'booked' => (int)($row['booked'] ?? 0) === 1, + 'department_id' => (int)($row['department_id'] ?? 0), + 'customer_number' => $customerNumber, + 'reference' => (string)($row['reference'] ?? ''), + 'po' => (string)($row['po'] ?? ''), + 'notes' => (string)($row['notes'] ?? ''), + 'reg_1' => (string)($row['reg_1'] ?? ''), + 'reg_2' => (string)($row['reg_2'] ?? ''), + 'reg_3' => (string)($row['reg_3'] ?? ''), + 'excluded' => (int)($row['include_in_invoice_effective'] ?? 1) !== 1, + 'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null, + 'queue_status' => null, + 'queue_job_id' => null, + 'user_id' => isset($row['user_id']) ? (int)$row['user_id'] : null, + 'customer_name' => (string)($row['customer_name'] ?? ''), + ]; + } + + return $transactions; + } + /** * Get orders with possible duplicates in a date range * @param string $dateFrom The start date of the date range (inclusive) "Y-m-d H:i:s" format diff --git a/services/nginx/app/objects/products_o.php b/services/nginx/app/objects/products_o.php index 6dc691d0..eea9c541 100644 --- a/services/nginx/app/objects/products_o.php +++ b/services/nginx/app/objects/products_o.php @@ -4,12 +4,16 @@ namespace objects; use classes\db; use classes\object_property; +use classes\products_schema_bootstrap; use traits\db_object_t; 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 @@ -70,6 +74,11 @@ class products_o extends db * @var object_property $order_priority */ public object_property $order_priority; + /** + * Optional upper quantity limit for a product on one order. + * @var object_property $max_quantity_per_order + */ + public object_property $max_quantity_per_order; /** * The timestamp of when the object was created * @var object_property @@ -83,6 +92,7 @@ class products_o extends db public function structure(): void { + products_schema_bootstrap::ensureTables(); $this->setTable('products'); } @@ -118,6 +128,7 @@ class products_o extends db $this->is_wash = new object_property($this->table, $this->id, 'is_wash', 'bool', false); $this->display_in_booking_form = new object_property($this->table, $this->id, 'display_in_booking_form', 'bool', false); $this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false); + $this->max_quantity_per_order = new object_property($this->table, $this->id, 'max_quantity_per_order', 'int', false); $this->created_at = new object_property($this->table, $this->id, 'created_at', 'string', false); $this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'string', false); } @@ -206,15 +217,38 @@ 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(), + 'max_quantity_per_order' => $this->max_quantity_per_order->value() === null ? null : (int)$this->max_quantity_per_order->value(), 'created_at' => (string)$this->created_at->value(), 'updated_at' => (string)$this->updated_at->value(), ]; } + 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 @@ -286,4 +320,4 @@ class products_o extends db self::requireSelected(); return $this->id === 41; } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/selfserve_wash_session_tasks_o.php b/services/nginx/app/objects/selfserve_wash_session_tasks_o.php index a9134b10..fe6d5f53 100644 --- a/services/nginx/app/objects/selfserve_wash_session_tasks_o.php +++ b/services/nginx/app/objects/selfserve_wash_session_tasks_o.php @@ -63,7 +63,7 @@ class selfserve_wash_session_tasks_o extends db 'description' => $description, 'services' => $services, 'buttons' => $buttons, - 'dynamic_images_vehicle_type' => (int)$thumb_position, // The rotations to do on the image. + 'dynamic_images_vehicle_type' => $thumb_position === null ? null : (int)$thumb_position, // The rotations to do on the image. ]); $this->getObjectProperties(); $this->objectChanged(); diff --git a/services/nginx/app/objects/selfserve_wash_sessions_o.php b/services/nginx/app/objects/selfserve_wash_sessions_o.php index e0192cd0..88a7ee6a 100644 --- a/services/nginx/app/objects/selfserve_wash_sessions_o.php +++ b/services/nginx/app/objects/selfserve_wash_sessions_o.php @@ -14,6 +14,11 @@ class selfserve_wash_sessions_o extends db { use db_object_t; + public const TERMINAL_STATUSES = [ + 'COMPLETED', + 'FORCE_STOPPED', + ]; + public object_property $lane_id; public object_property $department_id; public object_property $machine_type_id; @@ -105,6 +110,25 @@ class selfserve_wash_sessions_o extends db $this->status->set($status->value); } + public static function isTerminalStatus(?string $status): bool + { + return in_array(strtoupper(trim((string)$status)), self::TERMINAL_STATUSES, true); + } + + public static function terminalStatusSqlList(): string + { + return "'" . implode("','", array_map( + static fn(string $status): string => str_replace("'", "''", $status), + self::TERMINAL_STATUSES + )) . "'"; + } + + public function isOpen(): bool + { + return $this->completed_at->value() === null + && !self::isTerminalStatus((string)$this->status->value()); + } + public function markRelayEnabled(): void { $now = date('Y-m-d H:i:s'); @@ -142,6 +166,44 @@ class selfserve_wash_sessions_o extends db $this->status->set(selfserve_wash_session_status::COMPLETED->value); } + public function markForceStopped(?int $orderId = null, ?array $metadata = null): void + { + $this->completed_at->set(date('Y-m-d H:i:s')); + if ($orderId !== null) { + $this->order_id->set($orderId); + } + if ($metadata !== null) { + $existing = $this->metadata_json->value(); + $existing = is_array($existing) ? $existing : []; + $existing['force_stop'] = $metadata; + $this->metadata_json->set($existing); + } + $this->status->set(selfserve_wash_session_status::FORCE_STOPPED->value); + } + + public function selectLatestOpenByLane(int $laneId, ?int $customerNumber = null): self + { + $filters = [ + 'lane_id' => $laneId, + 'completed_at' => null, + 'deleted_at' => null, + ]; + if ($customerNumber !== null) { + $filters['customer_number'] = $customerNumber; + } + $rows = $this->getFieldsWhere($filters, ['id', 'status']); + $rows = array_values(array_filter( + $rows, + static fn(array $row): bool => !self::isTerminalStatus($row['status'] ?? null) + )); + if ($rows === []) { + return $this; + } + usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']); + $this->select((int)$rows[0]['id']); + return $this; + } + public function selectLatestOpenByLaneAndReg(int $laneId, string $reg, ?int $customerNumber = null): self { $filters = [ @@ -153,7 +215,11 @@ class selfserve_wash_sessions_o extends db if ($customerNumber !== null) { $filters['customer_number'] = $customerNumber; } - $rows = $this->getFieldsWhere($filters, ['id']); + $rows = $this->getFieldsWhere($filters, ['id', 'status']); + $rows = array_values(array_filter( + $rows, + static fn(array $row): bool => !self::isTerminalStatus($row['status'] ?? null) + )); if ($rows === []) { return $this; } @@ -198,6 +264,7 @@ class selfserve_wash_sessions_o extends db 'order_id' => $this->order_id->value() === null ? null : (int)$this->order_id->value(), 'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(), 'metadata' => (array)($this->metadata_json->value() ?? []), + 'open' => $this->isOpen(), 'created_at' => (string)$this->created_at->value(), 'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(), ]; @@ -212,16 +279,17 @@ class selfserve_wash_sessions_o extends db try { $start = new DateTime((string)$startAt); - $now = new DateTime(); + $endAt = $this->completed_at->value() ?? date('Y-m-d H:i:s'); + $end = new DateTime((string)$endAt); } catch (\Throwable) { return 0; } - if ($start > $now) { + if ($start > $end) { return 0; } - $diff = $start->diff($now); + $diff = $start->diff($end); return (int)(($diff->days * 24 * 60) + ($diff->h * 60) + $diff->i); } } diff --git a/services/nginx/app/objects/subuser_grants_o.php b/services/nginx/app/objects/subuser_grants_o.php index 1447b9e9..3c7b2641 100644 --- a/services/nginx/app/objects/subuser_grants_o.php +++ b/services/nginx/app/objects/subuser_grants_o.php @@ -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(); diff --git a/services/nginx/app/objects/subusers_o.php b/services/nginx/app/objects/subusers_o.php index b9402864..f7fd4b43 100644 --- a/services/nginx/app/objects/subusers_o.php +++ b/services/nginx/app/objects/subusers_o.php @@ -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; } diff --git a/services/nginx/app/objects/users_o.php b/services/nginx/app/objects/users_o.php index 2f57100b..242982dd 100644 --- a/services/nginx/app/objects/users_o.php +++ b/services/nginx/app/objects/users_o.php @@ -7,6 +7,7 @@ use classes\customer_name_cache_payload_builder; use classes\object_property; use classes\redis; use classes\response; +use classes\system_search_economic_customer_index; use classes\xlvask; use customers\economic_customer_mo; use customers\economicCustomers; @@ -48,6 +49,10 @@ class users_o extends db $this->setTable('users'); } + private static function redisCache(): ?redis + { + return defined('redis') ? constant('redis') : null; + } public function edit(int $id, string $customer_number, string|null $role, string|null $password, string|null $display_name): void { @@ -59,12 +64,12 @@ class users_o extends db if ($old_res && $old_res->num_rows > 0) { $old_cn = (int)$old_res->fetch_assoc()['customer_number']; if ($old_cn !== 0 && $old_cn !== (int)$customer_number) { - redis->clear_user_id_from_customer_number($old_cn); + self::redisCache()?->clear_user_id_from_customer_number($old_cn); } } // Cache the mapping from customer_number to user_id (new value) - redis->cache_user_id_from_customer_number((int)$customer_number, $this->id); - redis->cache_customer_number_from_user_id($this->id, (int)$customer_number); + self::redisCache()?->cache_user_id_from_customer_number((int)$customer_number, $this->id); + self::redisCache()?->cache_customer_number_from_user_id($this->id, (int)$customer_number); // Avoid SQL injection $customer_number = $db->escape_string($customer_number); @@ -235,8 +240,8 @@ class users_o extends db $this->id = (int)$db->insert_id(); // Cache the mapping from customer_number to user_id - redis->cache_user_id_from_customer_number((int)$customer_number, $this->id); - redis->cache_customer_number_from_user_id($this->id, (int)$customer_number); + self::redisCache()?->cache_user_id_from_customer_number((int)$customer_number, $this->id); + self::redisCache()?->cache_customer_number_from_user_id($this->id, (int)$customer_number); // Set the values of the object properties $this->getObjectProperties(); @@ -360,7 +365,7 @@ class users_o extends db { global $db; // Check Redis for existence (by checking if we have the customer number) - $customer_number = redis->get_customer_number_from_user_id($id); + $customer_number = self::redisCache()?->get_customer_number_from_user_id($id); if ($customer_number !== null) { $this->id = $id; $this->getObjectProperties(); @@ -374,7 +379,7 @@ class users_o extends db $this->id = $id; $customer_number = (int)$result->fetch_assoc()['customer_number']; // Cache the result - redis->cache_customer_number_from_user_id($id, $customer_number); + self::redisCache()?->cache_customer_number_from_user_id($id, $customer_number); $this->getObjectProperties(); } return $this; @@ -384,7 +389,7 @@ class users_o extends db { global $db; // Check Redis first - $user_id = redis->get_user_id_from_customer_number($customer_number); + $user_id = self::redisCache()?->get_user_id_from_customer_number($customer_number); if ($user_id !== null) { $this->id = (int)$user_id; $this->getObjectProperties(); @@ -397,7 +402,7 @@ class users_o extends db if ($result->num_rows > 0) { $this->id = (int)$result->fetch_assoc()['id']; // Cache the result - redis->cache_user_id_from_customer_number($customer_number, $this->id); + self::redisCache()?->cache_user_id_from_customer_number($customer_number, $this->id); $this->getObjectProperties(); } else { // Import the customer @@ -495,8 +500,9 @@ class users_o extends db } $cached = $tmp_user->getCached('economic_customer'); } - if ($cached && isset($cached->name) && is_string($cached->name) && trim($cached->name) !== '') { - return $cached->name; + $cachePayload = self::buildCustomerNameCachePayload($cached, $fallbackName); + if ($cachePayload !== null) { + return $cachePayload['name']; } return $fallbackName; } @@ -1073,22 +1079,22 @@ class users_o extends db public function clearAllUsersEconomicCustomerDiscountsFromCache(): void { // Get all the cached results matching the pattern 'users_*_economic_customer_discount_percentage' - $cached_results = redis->get_keys('users_*_economic_customer_discount_percentage'); + $cached_results = self::redisCache()?->get_keys('users_*_economic_customer_discount_percentage') ?? []; // Loop through the cached results foreach ( $cached_results as $key ) { // Clear the cached discount percentage - redis->delete($key); + self::redisCache()?->delete($key); } } public function clearAllUsersEconomicCustomerDetailsFromCache(): void { // Get all the cached results matching the pattern 'users_*_economic_customer' - $cached_results = redis->get_keys('users_*_economic_customer'); + $cached_results = self::redisCache()?->get_keys('users_*_economic_customer') ?? []; // Loop through the cached results foreach ( $cached_results as $key ) { // Clear the cached economic customer details - redis->delete($key); + self::redisCache()?->delete($key); } } @@ -1096,7 +1102,7 @@ class users_o extends db { self::requireSelected(); // Check if the discount percentage is cached - $cached_discount_percentage = redis->get_economic_customer_discount_percentage($this->id); + $cached_discount_percentage = self::redisCache()?->get_economic_customer_discount_percentage($this->id); if ($cached_discount_percentage !== null) { return $cached_discount_percentage; } @@ -1104,7 +1110,7 @@ class users_o extends db $economic = new economicCustomers(); $discount_percentage = $economic->getCustomerDiscountPercentage($this->customer_number->value()); // Cache the discount percentage - redis->cache_economic_customer_discount_percentage($this->id, $discount_percentage); + self::redisCache()?->cache_economic_customer_discount_percentage($this->id, $discount_percentage); return $discount_percentage; } @@ -1143,7 +1149,7 @@ class users_o extends db public function isImportedFromEconomic($customerNumber): bool { // Check Redis first - $user_id = redis->get_user_id_from_customer_number((int)$customerNumber); + $user_id = self::redisCache()?->get_user_id_from_customer_number((int)$customerNumber); if ($user_id !== null) { return true; } @@ -1158,7 +1164,7 @@ class users_o extends db public function getUserIdFromEconomic($customerNumber): int { // Check Redis first - $user_id = redis->get_user_id_from_customer_number((int)$customerNumber); + $user_id = self::redisCache()?->get_user_id_from_customer_number((int)$customerNumber); if ($user_id !== null) { return (int)$user_id; } @@ -1168,7 +1174,7 @@ class users_o extends db $id = (int)$user[0]['id']; // Cache the result - redis->cache_user_id_from_customer_number((int)$customerNumber, $id); + self::redisCache()?->cache_user_id_from_customer_number((int)$customerNumber, $id); return $id; } @@ -1530,16 +1536,21 @@ class users_o extends db * @param int[] $customer_numbers * @return array Map of customer number to customer name */ - public function getCustomerNames(array $customer_numbers): array + public function getCustomerNames(array $customer_numbers, bool $allowExternalFetch = true): array { global $db; $customer_numbers = array_map('intval', $customer_numbers); + if (empty($customer_numbers)) { + return []; + } + // Look in the cache first $customer_numbers_to_fetch = []; $customer_names_cached = self::getCachedForMultipleObjects('economic_customer_name', $customer_numbers); // Loop through the customer numbers and check if they are cached $customer_names = array_map(function ($cached_name) { - return $cached_name ? json_decode($cached_name)->name : null; + $cache_payload = self::buildCustomerNameCachePayload($cached_name, null); + return $cache_payload['name'] ?? null; }, array_values($customer_names_cached)); // Set the names for the cached customer numbers [ "customer_number" => "customer_name" ] $customer_names = array_combine( @@ -1552,11 +1563,24 @@ class users_o extends db $customer_numbers_to_fetch[] = (int)$customer_number; } } + $fallback_names = $this->getLocalDisplayNamesByCustomerNumber($customer_numbers_to_fetch); + $local_cached_names = $this->getCachedEconomicCustomerNamesByCustomerNumber($customer_numbers_to_fetch); + if (!$allowExternalFetch) { + foreach ($customer_numbers_to_fetch as $customer_number) { + $customer_names[(string)$customer_number] = $local_cached_names[$customer_number] ?? $fallback_names[$customer_number] ?? 'Unknown Customer'; + } + return $customer_names; + } + // Fetch the remaining customer names from E-conomic if (count($customer_numbers_to_fetch) > 0) { foreach ( $customer_numbers_to_fetch as $customer_number ) { + if (isset($local_cached_names[$customer_number])) { + $customer_names[(string)$customer_number] = $local_cached_names[$customer_number]; + continue; + } // Get the customer name from the external source - $fallback_name = null; + $fallback_name = $fallback_names[$customer_number] ?? null; try { // Try to get the economic customer data cached in the user $tmp_user = new users_o(); @@ -1589,6 +1613,142 @@ class users_o extends db return $customer_names; } + /** + * @param int[] $customer_numbers + * @return array + */ + private function getLocalDisplayNamesByCustomerNumber(array $customer_numbers): array + { + global $db; + + $customer_numbers = array_values(array_unique(array_filter(array_map('intval', $customer_numbers), static fn(int $customer_number): bool => $customer_number > 0))); + if (empty($customer_numbers)) { + return []; + } + + $sql = "SELECT customer_number, display_name FROM $this->table WHERE customer_number IN (" . implode(',', $customer_numbers) . ")"; + $result = $db->query($sql); + if (!$result) { + return []; + } + + $names = []; + while ($row = $result->fetch_assoc()) { + $customer_number = (int)($row['customer_number'] ?? 0); + $display_name = trim((string)($row['display_name'] ?? '')); + if ($customer_number > 0 && $display_name !== '') { + $names[$customer_number] = $display_name; + } + } + + return $names; + } + + /** + * Resolve names from local e-conomic snapshots only. This keeps period/listing + * requests fast while still avoiding "Unnamed" fallbacks when a richer cached + * e-conomic customer payload already exists. + * + * @param int[] $customer_numbers + * @return array + */ + private function getCachedEconomicCustomerNamesByCustomerNumber(array $customer_numbers): array + { + global $db; + + $customer_numbers = array_values(array_unique(array_filter(array_map('intval', $customer_numbers), static fn(int $customer_number): bool => $customer_number > 0))); + if (empty($customer_numbers)) { + return []; + } + + $sql = "SELECT id, customer_number FROM $this->table WHERE customer_number IN (" . implode(',', $customer_numbers) . ")"; + $result = $db->query($sql); + if (!$result) { + return $this->getIndexedEconomicCustomerNamesByCustomerNumber($customer_numbers); + } + + $user_ids_by_customer_number = []; + while ($row = $result->fetch_assoc()) { + $customer_number = (int)($row['customer_number'] ?? 0); + $user_id = (int)($row['id'] ?? 0); + if ($customer_number <= 0 || $user_id <= 0) { + continue; + } + + $user_ids_by_customer_number[$customer_number] = $user_id; + } + + $names = []; + $customer_numbers_by_index = array_keys($user_ids_by_customer_number); + $cached_names = $this->getCachedForMultipleObjects('economic_customer', array_values($user_ids_by_customer_number)); + foreach ($customer_numbers_by_index as $index => $customer_number) { + $cached_name = $cached_names[$index] ?? null; + $cache_payload = self::buildCustomerNameCachePayload($cached_name, null); + if ($cache_payload === null) { + continue; + } + + $names[$customer_number] = $cache_payload['name']; + $this->cache('economic_customer_name', $cache_payload, $customer_number); + $this->setCachedExpiration('economic_customer_name', self::$economicCustomerNameCacheExpiration, $customer_number); + } + + $missing_customer_numbers = array_values(array_diff($customer_numbers, array_keys($names))); + if (!empty($missing_customer_numbers)) { + foreach ($this->getIndexedEconomicCustomerNamesByCustomerNumber($missing_customer_numbers) as $customer_number => $name) { + $cache_payload = self::buildCustomerNameCachePayload((object)['name' => $name], null); + if ($cache_payload === null) { + continue; + } + + $names[$customer_number] = $cache_payload['name']; + $this->cache('economic_customer_name', $cache_payload, $customer_number); + $this->setCachedExpiration('economic_customer_name', self::$economicCustomerNameCacheExpiration, $customer_number); + } + } + + return $names; + } + + /** + * @param int[] $customer_numbers + * @return array + */ + private function getIndexedEconomicCustomerNamesByCustomerNumber(array $customer_numbers): array + { + global $db; + + $customer_numbers = array_values(array_unique(array_filter(array_map('intval', $customer_numbers), static fn(int $customer_number): bool => $customer_number > 0))); + if (empty($customer_numbers)) { + return []; + } + + try { + system_search_economic_customer_index::ensureTable(); + } catch (\Throwable) { + return []; + } + + $result = $db->query( + "SELECT customer_number, economic_name FROM `" . system_search_economic_customer_index::TABLE . "`" + . " WHERE customer_number IN (" . implode(',', $customer_numbers) . ")" + ); + if (!$result) { + return []; + } + + $names = []; + while ($row = $result->fetch_assoc()) { + $customer_number = (int)($row['customer_number'] ?? 0); + $cache_payload = self::buildCustomerNameCachePayload((object)['name' => $row['economic_name'] ?? null], null); + if ($customer_number > 0 && $cache_payload !== null) { + $names[$customer_number] = $cache_payload['name']; + } + } + + return $names; + } + /** * @param int[] $cashier_ids * @return array Map of cashier id => display name diff --git a/services/nginx/app/objects/xlvask_usage_logs_o.php b/services/nginx/app/objects/xlvask_usage_logs_o.php index 6d1c6d49..ac30d7d9 100644 --- a/services/nginx/app/objects/xlvask_usage_logs_o.php +++ b/services/nginx/app/objects/xlvask_usage_logs_o.php @@ -5,6 +5,7 @@ namespace objects; use classes\db; use classes\object_property; use classes\xlvask; +use classes\xlvask_usage_logs_schema_bootstrap; use Exception; use helpers\xlvask_customer; use helpers\xlvask_usage_log; @@ -35,9 +36,13 @@ class xlvask_usage_logs_o extends db public object_property $CustomerGuid; public object_property $VehicleId; public object_property $WashItems; + public object_property $ignored_at; + public object_property $ignored_by; + public object_property $ignored_reason; public function structure(): void { + xlvask_usage_logs_schema_bootstrap::ensureTables(); $this->setTable('xlvask_usage_logs'); } @@ -77,6 +82,9 @@ class xlvask_usage_logs_o extends db $this->CustomerGuid = new object_property($this->table, $this->id, 'CustomerGuid', 'string', false); $this->VehicleId = new object_property($this->table, $this->id, 'VehicleId', 'string', false); $this->WashItems = new object_property($this->table, $this->id, 'WashItems', 'string', false); + $this->ignored_at = new object_property($this->table, $this->id, 'ignored_at', 'string', false); + $this->ignored_by = new object_property($this->table, $this->id, 'ignored_by', 'int', false); + $this->ignored_reason = new object_property($this->table, $this->id, 'ignored_reason', 'string', false); } public function objectChanged(): void @@ -84,6 +92,107 @@ class xlvask_usage_logs_o extends db //TODO: Add cache invalidation } + public function getCachedAmountSummaryFromRow(array $row): array + { + $cached_amount = self::normalizeMoneyValue($row['cached_total_net_amount'] ?? null); + $cached_at = trim((string)($row['cached_amount_at'] ?? '')); + + if ($cached_amount !== null && $cached_at !== '') { + return [ + 'total_net_amount' => $cached_amount, + 'primary_product_name' => (string)($row['cached_primary_product_name'] ?? ''), + 'cached' => true, + ]; + } + + $summary = self::calculateAmountSummaryFromWashItems($row['WashItems'] ?? []); + $id = (int)($row['id'] ?? 0); + if ($id > 0) { + self::cacheAmountSummary($id, $summary); + } + + return [ + ...$summary, + 'cached' => false, + ]; + } + + public static function calculateAmountSummaryFromWashItems(array|string|null $washItems): array + { + if (is_string($washItems)) { + $decoded = json_decode($washItems, true); + $washItems = is_array($decoded) ? $decoded : []; + } + + $total = 0.0; + $primaryProductName = ''; + foreach (is_array($washItems) ? $washItems : [] as $item) { + if (!is_array($item)) { + continue; + } + + if ($primaryProductName === '' && isset($item['OriginalProductName'])) { + $primaryProductName = trim((string)$item['OriginalProductName']); + } + + $priceIncVat = self::normalizeMoneyValue($item['PriceIncVat'] ?? null); + $vat = self::normalizeMoneyValue($item['Vat'] ?? 0.0) ?? 0.0; + if ($priceIncVat === null) { + continue; + } + + $total += $priceIncVat - $vat; + } + + return [ + 'total_net_amount' => round($total, 2), + 'primary_product_name' => $primaryProductName, + ]; + } + + private static function cacheAmountSummary(int $id, array $summary): void + { + global $db; + + if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) { + return; + } + + $amount = number_format((float)($summary['total_net_amount'] ?? 0.0), 2, '.', ''); + $primaryProductName = $db->escape_string((string)($summary['primary_product_name'] ?? '')); + $db->query( + "UPDATE xlvask_usage_logs + SET cached_total_net_amount = {$amount}, + cached_primary_product_name = " . ($primaryProductName === '' ? 'NULL' : "'{$primaryProductName}'") . ", + cached_amount_at = NOW() + WHERE id = {$id}" + ); + } + + private static function normalizeMoneyValue(mixed $value): ?float + { + if ($value === null || $value === '') { + return null; + } + + if (is_int($value) || is_float($value)) { + return (float)$value; + } + + $normalized = preg_replace('/[^\d,.\-]/', '', (string)$value); + if ($normalized === null || $normalized === '') { + return null; + } + + if (str_contains($normalized, ',') && !str_contains($normalized, '.')) { + $normalized = str_replace(',', '.', $normalized); + } else { + $normalized = str_replace(',', '', $normalized); + } + + return is_numeric($normalized) ? (float)$normalized : null; + } + /** * Import the usage logs from XL Vask * @param string $dateTimeModifier A date time modifier to use for the import, defaults to '-7 days' @@ -150,4 +259,4 @@ class xlvask_usage_logs_o extends db )); return $vehicles; } -} \ No newline at end of file +} diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index d38afb22..f3a0f23d 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -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: @@ -2204,6 +2362,8 @@ paths: transaction_draft_customer_number: type: integer nullable: true + default_distribution_department_id: + type: integer additionalProperties: false additionalProperties: true '400': @@ -3146,12 +3306,55 @@ paths: required: true schema: type: integer + - name: confirmed + in: query + required: false + description: Must be true to delete an order that is completed or has active items or attachments. + schema: + type: boolean responses: '200': description: Order deleted successfully content: application/json: schema: {} + '409': + description: Order deletion requires explicit confirmation + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: false + data: + type: object + properties: + message: + type: string + example: Order deletion requires confirmation + requires_confirmation: + type: boolean + example: true + protected_reasons: + type: array + items: + type: string + enum: [completed, order_items, attachments] + order_item_count: + type: integer + example: 2 + attachment_count: + type: integer + example: 1 + completed_at: + type: string + nullable: true + meta: + type: object + includes: + type: object '401': $ref: '#/components/responses/Unauthorized' '404': @@ -3870,13 +4073,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 @@ -4674,6 +4879,22 @@ paths: customer_id: type: integer nullable: true + vehicle_type: + type: integer + nullable: true + description: Optional product/vehicle type override used when refreshing the self-serve summary. + vehicle_type_id: + type: integer + nullable: true + description: Alias for vehicle_type. + activate_machine: + type: boolean + default: true + description: Whether the session synchronization may enable the machine relay. User wash-start saves answers with false. + sync_relay_state: + type: boolean + default: true + description: Whether the answer mutation should synchronize live relay state. responses: '200': description: Successfully added vehicle condition @@ -4718,6 +4939,22 @@ paths: customer_id: type: integer nullable: true + vehicle_type: + type: integer + nullable: true + description: Optional product/vehicle type override used when refreshing the self-serve summary. + vehicle_type_id: + type: integer + nullable: true + description: Alias for vehicle_type. + activate_machine: + type: boolean + default: true + description: Whether the session synchronization may enable the machine relay. + sync_relay_state: + type: boolean + default: true + description: Whether the mutation should synchronize live relay state. responses: '200': description: Successfully updated vehicle condition @@ -4770,6 +5007,7 @@ paths: tags: - Self-Serve summary: Check whether self-serve is allowed for a vehicle on a lane + description: Customers with own self-serve permissions may evaluate any registration plate for their wash. Persisted self-serve answers are only applied when they are scoped to the authenticated customer. operationId: getSelfserveVehicleAllowed parameters: - name: lane_id @@ -5198,6 +5436,297 @@ paths: '404': $ref: '#/components/responses/NotFound' + /department/selfserve/studio/graph: + get: + tags: + - Self-Serve + summary: Get all-in-one self-serve studio graph + description: Returns the replacement studio workspace graph backed by the schema_version 2 draft config. Conditions own grouped expression trees directly; standalone rule nodes are omitted from v2 graphs. + operationId: getSelfserveStudioGraph + parameters: + - name: department + in: query + required: true + schema: + type: integer + responses: + '200': + description: Studio graph returned + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioGraph' + put: + tags: + - Self-Serve + summary: Bulk save all-in-one self-serve studio graph changes + description: Creates, updates, deletes, connects, disconnects, reorders, and upserts self-serve answer paths by editing the schema_version 2 draft config JSON. Condition connections create expression predicates; Path Editor upserts create normal generated condition and task nodes; layout remains separate from runtime behavior. + operationId: saveSelfserveStudioGraph + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioGraphSaveRequest' + responses: + '200': + description: Studio graph saved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioGraph' + '422': + $ref: '#/components/responses/BadRequest' + + /department/selfserve/studio/layout: + put: + tags: + - Self-Serve + summary: Save self-serve studio canvas layout + description: Persists canvas-only node positions and viewport state. Layout does not affect runtime wash behavior. + operationId: saveSelfserveStudioLayout + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioLayoutSaveRequest' + responses: + '200': + description: Layout saved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioLayout' + + /department/selfserve/studio/validate: + post: + tags: + - Self-Serve + summary: Validate self-serve studio graph + operationId: validateSelfserveStudioGraph + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department] + properties: + department: + type: integer + responses: + '200': + description: Validation result + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioValidation' + + /department/selfserve/studio/simulate: + post: + tags: + - Self-Serve + summary: Simulate self-serve studio runtime + operationId: simulateSelfserveStudioGraph + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department, lane_id, reg] + properties: + department: { type: integer } + lane_id: { type: integer } + reg: { type: string } + customer_number: { type: integer, nullable: true } + vehicle_type_id: { type: integer, nullable: true } + config_source: + type: string + enum: [draft, published] + default: draft + answer_overrides: + type: array + items: + type: object + required: [question_id] + properties: + question_id: { type: integer } + value: + type: boolean + nullable: true + include_hardware: + type: boolean + default: true + mode: + type: string + enum: [full_dry_run] + default: full_dry_run + responses: + '200': + description: Simulator result + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioSimulationResponse' + + /department/selfserve/studio/path-outcomes: + post: + tags: + - Self-Serve + summary: Project grouped self-serve studio question path outcomes + description: Enumerates feasible yes/no answer paths for the selected studio scope and groups terminal paths by resulting tasks, services, and dry-run signal timeline. No live hardware commands are sent. + operationId: projectSelfserveStudioPathOutcomes + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioPathOutcomesRequest' + responses: + '200': + description: Grouped path outcomes returned + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioPathOutcomesResponse' + '422': + $ref: '#/components/responses/BadRequest' + + /department/selfserve/studio/path-outcomes/stream: + post: + tags: + - Self-Serve + summary: Stream self-serve studio question path outcome progress + description: Streams newline-delimited JSON progress events while enumerating the complete feasible yes/no answer path space. Progress events contain the same response shape as the final result with partial outcomes and paths. + operationId: streamSelfserveStudioPathOutcomes + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioPathOutcomesRequest' + responses: + '200': + description: Newline-delimited path outcome progress events + content: + application/x-ndjson: + schema: + type: string + '422': + $ref: '#/components/responses/BadRequest' + + /department/selfserve/studio/path-confirmations: + post: + tags: + - Self-Serve + summary: Confirm or reset a projected self-serve studio path + description: Stores confirmation for a projected terminal path using its stable path and result signatures. Projections report confirmed, unconfirmed, or stale when the resulting tasks, buttons, services, or signals change. + operationId: confirmSelfserveStudioPath + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioPathConfirmationRequest' + responses: + '200': + description: Path confirmation updated + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveStudioPathConfirmation' + '422': + $ref: '#/components/responses/BadRequest' + + /department/selfserve/studio/publish: + post: + tags: + - Self-Serve + summary: Publish self-serve studio draft + operationId: publishSelfserveStudioDraft + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department] + properties: + department: { type: integer } + responses: + '200': + description: Published version + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveConfigVersion' + + /department/selfserve/studio/rollback: + post: + tags: + - Self-Serve + summary: Roll back self-serve studio to an earlier version + operationId: rollbackSelfserveStudioDraft + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department, target_version_id] + properties: + department: { type: integer } + target_version_id: { type: integer } + responses: + '200': + description: Rollback version + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveConfigVersion' + + /department/selfserve/studio/gateway-action: + post: + tags: + - Self-Serve + summary: Run permission-gated edge gateway action from studio + operationId: runSelfserveStudioGatewayAction + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department, gateway_id, action] + properties: + department: { type: integer } + gateway_id: { type: integer } + action: + type: string + enum: [discovery, discover, update, uninstall, cancel, rotate_credentials, bindings] + confirm: + type: boolean + description: Required for dangerous gateway actions such as uninstall and credential rotation. + operation_id: { type: integer, nullable: true } + request: + type: object + additionalProperties: true + bindings: + type: array + items: + type: object + additionalProperties: true + responses: + '200': + description: Gateway action result + content: + application/json: + schema: + type: object + additionalProperties: true + # Products Endpoints /products: get: @@ -5684,7 +6213,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: @@ -6163,6 +6692,34 @@ paths: '503': { $ref: '#/components/responses/ServiceUnavailable' } '500': { $ref: '#/components/responses/InternalServerError' } + /collected-invoices/economic/queue/monitor: + get: + tags: + - Invoices + summary: Monitor current-user visible collected-invoice e-conomic transfer queue jobs + operationId: monitorCollectedInvoiceEconomicQueueJobs + parameters: + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + responses: + '200': + description: Queue monitor state retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueMonitorResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + /collected-invoices/economic/queue/status: get: tags: @@ -6222,6 +6779,57 @@ paths: '503': { $ref: '#/components/responses/ServiceUnavailable' } '500': { $ref: '#/components/responses/InternalServerError' } + /collected-invoices/economic/queue/dismiss: + post: + tags: + - Invoices + summary: Clear one completed or failed collected-invoice queue job for the current user + operationId: dismissCollectedInvoiceEconomicQueueJob + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [job_id] + properties: + job_id: + type: integer + minimum: 1 + responses: + '200': + description: Queue job cleared + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueDismissResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue/dismiss-terminal: + post: + tags: + - Invoices + summary: Clear all visible completed or failed collected-invoice queue jobs for the current user + operationId: dismissCollectedInvoiceEconomicTerminalQueueJobs + responses: + '200': + description: Terminal queue jobs cleared + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueDismissTerminalResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + '500': { $ref: '#/components/responses/InternalServerError' } + /collected-invoices/economic/queue/run: post: tags: @@ -7462,7 +8070,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: @@ -7813,6 +8429,93 @@ paths: '404': $ref: '#/components/responses/NotFound' + /relay/machine/on/post: + get: + tags: + - Plate Scans + summary: Record Shelly machine ON signal webhook + description: Accepts Shelly Cloud webhook/query parameters for input.toggle_on or switch.on and records the physical machine start signal for self-serve billing. + operationId: recordShellyMachineOnSignal + parameters: + - name: token + in: query + required: false + schema: {type: string} + - name: lane_id + in: query + required: false + schema: + type: integer + - name: relay_id + in: query + required: false + schema: + type: string + - name: event + in: query + required: false + schema: + type: string + enum: [input.toggle_on, switch.on] + - name: reg + in: query + required: false + schema: + type: string + responses: + '201': + description: Machine ON signal recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '202': + description: Shelly signal was recognized but ignored + '400': + $ref: '#/components/responses/BadRequest' + post: + tags: + - Plate Scans + summary: Record Shelly machine ON signal webhook + description: Accepts Shelly Cloud JSON webhook payloads for input.toggle_on or switch.on and records the physical machine start signal for self-serve billing. + operationId: recordShellyMachineOnSignalPost + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + token: + type: string + lane_id: + type: integer + relay_id: + type: string + event: + type: string + enum: [input.toggle_on, switch.on] + component: + type: string + example: input:0 + state: + type: boolean + output: + type: boolean + reg: + type: string + responses: + '201': + description: Machine ON signal recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '202': + description: Shelly signal was recognized but ignored + '400': + $ref: '#/components/responses/BadRequest' + # Module - e-conomic Endpoints /economic/customers/import: post: @@ -8505,6 +9208,120 @@ paths: '403': $ref: '#/components/responses/Forbidden' + /modules/self-serve/sessions: + get: + tags: + - Modules + summary: List self-serve wash sessions + description: Retrieve paginated self-serve wash sessions with search, filters, ordering, and active/open-only support. + operationId: listSelfServeSessions + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/LimitParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/FiltersParam' + - name: order + in: query + required: false + schema: + type: string + example: id:DESC + - name: open_only + in: query + required: false + schema: + type: boolean + responses: + '200': + description: Self-serve wash sessions retrieved successfully + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: '#/components/schemas/SelfserveWashSession' + - type: object + properties: + elapsed_minutes: + type: integer + open: + type: boolean + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/sessions/{id}: + get: + tags: + - Modules + summary: Get self-serve wash session detail + operationId: getSelfServeSessionDetail + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Self-serve wash session detail retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveWashSummary' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /modules/self-serve/lane/force/stop: + post: + tags: + - Modules + summary: Force stop a self-serve wash session + description: | + Clears the current self-serve wash session and lane runtime with RESET behavior only. + This administrative action does not signal relays or gates. When billing is requested, + only elapsed-minute billing is attempted before runtime is cleared. + operationId: forceStopSelfServeLane + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - bill + properties: + lane_id: + type: integer + session_id: + type: integer + nullable: true + bill: + type: boolean + reason: + type: string + nullable: true + responses: + '200': + description: Self-serve wash force stopped successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveForceStopResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + /modules/self-serve/lane/command: post: tags: @@ -8513,6 +9330,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 @@ -8532,6 +9353,13 @@ paths: license_plate: type: string description: Required for START command + customer_number: + type: integer + description: Required for START and RESERVE commands. The authenticated customer's number is applied server-side when omitted by user clients. + defer_relay_side_effects: + type: boolean + default: false + description: For START, open the entrance gate as part of the command but defer cleaner and machine relay activation to explicit relay endpoints. responses: '200': description: Command sent successfully @@ -8563,6 +9391,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 @@ -8885,7 +9716,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. operationId: enableSelfServeLaneMachineRelay requestBody: required: true @@ -9948,6 +10781,652 @@ paths: '403': $ref: '#/components/responses/Forbidden' + /superuser/replication: + get: + tags: + - Superuser + summary: Database, Redis, and MinIO replication topology + operationId: getSuperuserReplication + parameters: + - in: query + name: refresh + required: false + schema: + type: boolean + default: false + description: Refresh host connectivity and replication status before returning the topology. + responses: + '200': + description: Replication topology returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationResponse' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/replication/databases: + post: + tags: + - Superuser + summary: Add database replication host credentials + operationId: addSuperuserDatabaseReplicationHost + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationHostCreateRequest' + responses: + '201': + description: Database replication host added + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationHostResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/replication/redis: + post: + tags: + - Superuser + summary: Add Redis replication host credentials + operationId: addSuperuserRedisReplicationHost + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationHostCreateRequest' + responses: + '201': + description: Redis replication host added + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationHostResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/replication/minio: + post: + tags: + - Superuser + summary: Add MinIO replication host credentials + operationId: addSuperuserMinioReplicationHost + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationHostCreateRequest' + responses: + '201': + description: MinIO replication host added + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationHostResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/replication/compose-template: + post: + tags: + - Superuser + summary: Generate a replication-ready Docker Compose template + operationId: generateSuperuserReplicationComposeTemplate + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationComposeTemplateRequest' + responses: + '200': + description: Docker Compose template generated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationComposeTemplateResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/replication/test-credentials: + post: + tags: + - Superuser + summary: Test replication host credentials before saving + operationId: testSuperuserReplicationCredentials + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationUnsavedCredentialTestRequest' + responses: + '200': + description: Credential test returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationOperationResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/replication/{kind}/{id}/test: + post: + tags: + - Superuser + summary: Test replication host connectivity and privileges + operationId: testSuperuserReplicationHost + parameters: + - $ref: '#/components/parameters/SuperuserReplicationKindParam' + - $ref: '#/components/parameters/SuperuserReplicationHostIdParam' + responses: + '200': + description: Host test result returned + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationOperationResponse' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /superuser/replication/{kind}/{id}/provision: + post: + tags: + - Superuser + summary: Provision a host as a replica of the current primary + operationId: provisionSuperuserReplicationHost + parameters: + - $ref: '#/components/parameters/SuperuserReplicationKindParam' + - $ref: '#/components/parameters/SuperuserReplicationHostIdParam' + responses: + '200': + description: Replica provisioning started or completed + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationOperationResponse' + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/replication/{kind}/{id}/promote: + post: + tags: + - Superuser + summary: Promote a caught-up replica to primary + operationId: promoteSuperuserReplicationHost + parameters: + - $ref: '#/components/parameters/SuperuserReplicationKindParam' + - $ref: '#/components/parameters/SuperuserReplicationHostIdParam' + responses: + '200': + description: Replica promoted to primary + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationOperationResponse' + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/replication/{kind}/{id}: + patch: + tags: + - Superuser + summary: Rename a replication host + operationId: renameSuperuserReplicationHost + parameters: + - $ref: '#/components/parameters/SuperuserReplicationKindParam' + - $ref: '#/components/parameters/SuperuserReplicationHostIdParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationHostRenameRequest' + responses: + '200': + description: Replication host renamed + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationHostResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: + - Superuser + summary: Remove an inactive or unhealthy replication host + operationId: removeSuperuserReplicationHost + parameters: + - $ref: '#/components/parameters/SuperuserReplicationKindParam' + - $ref: '#/components/parameters/SuperuserReplicationHostIdParam' + responses: + '200': + description: Replication host removed + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserReplicationOperationResponse' + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify: + get: + tags: + - Superuser + summary: Coolify-managed replicated infrastructure state + operationId: getSuperuserCoolify + responses: + '200': + description: Coolify summary returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyResponse' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/load-balancer: + get: + tags: + - Superuser + summary: Coolify public gateway Load Balancer state + operationId: getSuperuserCoolifyLoadBalancer + responses: + '200': + description: Load Balancer summary returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyLoadBalancerResponse' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/load-balancer/reconcile: + post: + tags: + - Superuser + summary: Reconcile Coolify public gateway Load Balancer state + operationId: reconcileSuperuserCoolifyLoadBalancer + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + dry_run: + type: boolean + default: true + enforce: + type: boolean + default: false + responses: + '200': + description: Load Balancer reconcile result returned + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyLoadBalancerReconcileResponse' + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/load-balancer/routes/deploy: + post: + tags: + - Superuser + summary: Deploy the Coolify API route for the public gateway host + operationId: deploySuperuserCoolifyGatewayRoutes + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + dry_run: + type: boolean + default: true + enforce: + type: boolean + default: false + responses: + '200': + description: Gateway application route deploy result returned + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyGatewayRouteDeployResponse' + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/load-balancer/api/deploy: + post: + tags: + - Superuser + summary: Deploy the latest Coolify API code for the public gateway host + operationId: deploySuperuserCoolifyGatewayApiCode + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + dry_run: + type: boolean + default: true + enforce: + type: boolean + default: false + deploy_routes: + type: boolean + default: true + responses: + '200': + description: Gateway API code deployment result returned + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyGatewayRouteDeployResponse' + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/gateways: + get: + tags: + - Superuser + summary: List Coolify public gateway Load Balancer targets + operationId: listSuperuserCoolifyGateways + responses: + '200': + description: Gateway targets returned + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyGatewaysResponse' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + post: + tags: + - Superuser + summary: Create or update a Coolify public gateway Load Balancer target + operationId: saveSuperuserCoolifyGateway + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyGatewaySaveRequest' + responses: + '201': + description: Gateway target saved + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyGatewayResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/gateways/{id}/test: + post: + tags: + - Superuser + summary: Probe a Coolify public gateway target + operationId: testSuperuserCoolifyGateway + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: Gateway target probe result returned + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyOperationResponse' + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/instances: + post: + tags: + - Superuser + summary: Create Coolify API connection + operationId: createSuperuserCoolifyInstance + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyInstanceCreateRequest' + responses: + '201': + description: Coolify instance created + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyInstanceResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/instances/{id}/test: + post: + tags: + - Superuser + summary: Test Coolify API connection + operationId: testSuperuserCoolifyInstance + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: Coolify connection test returned + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyOperationResponse' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /superuser/coolify/instances/{id}/placement: + get: + tags: + - Superuser + summary: Discover Coolify placement options + operationId: discoverSuperuserCoolifyInstancePlacement + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: Coolify project, environment, and server options returned + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyPlacementResponse' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /superuser/coolify/targets: + get: + tags: + - Superuser + summary: List Coolify-managed replication targets + operationId: listSuperuserCoolifyTargets + parameters: + - in: query + name: kind + required: false + schema: + type: string + enum: [database, redis, minio] + responses: + '200': + description: Coolify targets returned + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyTargetsResponse' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + post: + tags: + - Superuser + summary: Create Coolify-managed passive replication target + operationId: createSuperuserCoolifyTarget + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyTargetCreateRequest' + responses: + '201': + description: Coolify target created + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserCoolifyOperationResponse' + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/targets/{id}/reconcile: + post: + tags: + - Superuser + summary: Reconcile a passive Coolify target + operationId: reconcileSuperuserCoolifyTarget + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': { description: Reconcile completed or queued } + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/targets/{id}/deploy: + post: + tags: + - Superuser + summary: Deploy and provision a passive Coolify target + operationId: deploySuperuserCoolifyTarget + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': { description: Deploy and provision flow completed or queued } + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/targets/{id}/restart: + post: + tags: + - Superuser + summary: Restart a passive Coolify target + operationId: restartSuperuserCoolifyTarget + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': { description: Restart requested } + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/targets/{id}/failover: + post: + tags: + - Superuser + summary: Promote a Coolify-managed replica through replication failover + operationId: failoverSuperuserCoolifyTarget + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': { description: Failover action returned } + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /superuser/coolify/targets/{id}: + delete: + tags: + - Superuser + summary: Delete a Coolify target mapping with destructive confirmation + operationId: deleteSuperuserCoolifyTarget + parameters: + - in: path + name: id + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [confirm] + properties: + confirm: + type: string + description: Must equal delete-coolify-target-{id}. + delete_resource: + type: boolean + default: false + responses: + '200': { description: Target deleted } + '409': { $ref: '#/components/responses/Conflict' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + # Configuration Endpoints /economic/config: get: @@ -10615,12 +12094,23 @@ paths: name: {type: string} description: {type: string} cvr: {type: integer} + address: {type: string, nullable: true} + phone_country_code: {type: integer, nullable: true} + phone: {type: integer, nullable: true} + email: {type: string, nullable: true} + website: {type: string, nullable: true} + banner: {type: string, nullable: true} + logo: {type: string, nullable: true} + favicon: {type: string, nullable: true} + signature: {type: string, nullable: true} responses: '200': description: Branding option added successfully content: application/json: schema: {} + '400': + $ref: '#/components/responses/BadRequest' '403': $ref: '#/components/responses/Forbidden' put: @@ -10638,17 +12128,30 @@ paths: required: [id] properties: id: {type: integer} - name: {type: string} - description: {type: string} - cvr: {type: integer} + name: {type: string, nullable: true} + description: {type: string, nullable: true} + cvr: {type: integer, nullable: true} + address: {type: string, nullable: true} + phone_country_code: {type: integer, nullable: true} + phone: {type: integer, nullable: true} + email: {type: string, nullable: true} + website: {type: string, nullable: true} + banner: {type: string, nullable: true} + logo: {type: string, nullable: true} + favicon: {type: string, nullable: true} + signature: {type: string, nullable: true} responses: '200': description: Branding option updated successfully content: application/json: schema: {} + '400': + $ref: '#/components/responses/BadRequest' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' /roles: get: @@ -10822,6 +12325,36 @@ paths: application/json: schema: {} + /superuser/department/branding: + put: + tags: + - Departments + summary: Set department branding + description: Assign an existing branding option to a department, or clear the department branding by sending a null branding_id. + operationId: setDepartmentBranding + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department_id, branding_id] + properties: + department_id: {type: integer} + branding_id: {type: integer, nullable: true} + responses: + '200': + description: Department branding updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /superuser/department/prices: get: tags: @@ -11340,6 +12873,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: @@ -11397,6 +13113,20 @@ components: the target customer can be inferred from context. schema: type: integer + SuperuserReplicationKindParam: + name: kind + in: path + required: true + schema: + type: string + enum: [databases, redis, minio] + SuperuserReplicationHostIdParam: + name: id + in: path + required: true + schema: + type: integer + minimum: 1 responses: BadRequest: @@ -11453,6 +13183,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: @@ -11472,6 +13378,1055 @@ components: - meta - includes + SuperuserReplicationResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/SuperuserReplicationSummary' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserReplicationHostResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/SuperuserReplicationHost' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserReplicationOperationResponse: + type: object + properties: + success: + type: boolean + data: + type: object + additionalProperties: true + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + generated_at: + type: string + format: date-time + instances: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyInstance' + targets: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyTarget' + availability: + type: object + additionalProperties: true + load_balancer: + $ref: '#/components/schemas/SuperuserCoolifyLoadBalancer' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyInstanceResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/SuperuserCoolifyInstance' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyTargetsResponse: + type: object + properties: + success: + type: boolean + data: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyTarget' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyOperationResponse: + type: object + properties: + success: + type: boolean + data: + type: object + additionalProperties: true + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyLoadBalancerResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/SuperuserCoolifyLoadBalancer' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyLoadBalancerReconcileResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + ok: + type: boolean + dry_run: + type: boolean + mutated: + type: boolean + config: + $ref: '#/components/schemas/SuperuserCoolifyLoadBalancerConfig' + load_balancer: + $ref: '#/components/schemas/SuperuserCoolifyLoadBalancerPublicState' + drift: + type: object + additionalProperties: true + planned: + type: array + items: + type: object + additionalProperties: true + applied: + type: array + items: + type: object + additionalProperties: true + skipped: + type: array + items: + type: object + additionalProperties: true + errors: + type: array + items: + type: object + additionalProperties: true + gateways: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyGateway' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyGatewayRouteDeployResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + ok: + type: boolean + dry_run: + type: boolean + mutated: + type: boolean + public_host: + type: string + public_url: + type: string + planned: + type: array + items: + type: object + additionalProperties: true + applied: + type: array + items: + type: object + additionalProperties: true + skipped: + type: array + items: + type: object + additionalProperties: true + errors: + type: array + items: + type: object + additionalProperties: true + warnings: + type: array + items: + type: string + coverage: + type: object + additionalProperties: true + gateways: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyGateway' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyGatewaysResponse: + type: object + properties: + success: + type: boolean + data: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyGateway' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyGatewayResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/SuperuserCoolifyGateway' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserCoolifyLoadBalancer: + type: object + properties: + configured: + type: boolean + status: + type: string + enum: [not_configured, ok, degraded, down] + config: + $ref: '#/components/schemas/SuperuserCoolifyLoadBalancerConfig' + gateways: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyGateway' + load_balancer: + nullable: true + allOf: + - $ref: '#/components/schemas/SuperuserCoolifyLoadBalancerPublicState' + drift: + type: object + additionalProperties: true + last_error: + type: string + nullable: true + + SuperuserCoolifyLoadBalancerConfig: + type: object + properties: + automation_enabled: + type: boolean + automation_mode: + type: string + enum: [report_only, enforce] + load_balancer_id: + type: string + public_gateway_host: + type: string + token_set: + type: boolean + token_source: + type: string + nullable: true + required_services: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyLoadBalancerService' + + SuperuserCoolifyLoadBalancerPublicState: + type: object + properties: + id: + type: integer + nullable: true + name: + type: string + ipv4: + type: string + nullable: true + ipv6: + type: string + nullable: true + location: + type: string + nullable: true + algorithm: + type: string + nullable: true + targets: + type: array + items: + type: string + services: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyLoadBalancerService' + + SuperuserCoolifyLoadBalancerService: + type: object + properties: + protocol: + type: string + enum: [http, tcp] + listen_port: + type: integer + destination_port: + type: integer + proxyprotocol: + type: boolean + + SuperuserCoolifyGateway: + type: object + properties: + id: + type: integer + instance_id: + type: integer + nullable: true + hostname: + type: string + target_ip: + type: string + enabled: + type: boolean + priority: + type: integer + health_state: + type: string + lb_state: + type: string + last_probe: + type: object + nullable: true + additionalProperties: true + last_probed_at: + type: string + nullable: true + last_reconciled_at: + type: string + nullable: true + created_at: + type: string + nullable: true + updated_at: + type: string + nullable: true + + SuperuserCoolifyGatewaySaveRequest: + type: object + required: + - hostname + - target_ip + properties: + id: + type: integer + instance_id: + type: integer + nullable: true + hostname: + type: string + target_ip: + type: string + enabled: + type: boolean + default: true + priority: + type: integer + default: 100 + + SuperuserCoolifyInstance: + type: object + properties: + id: + type: integer + label: + type: string + base_url: + type: string + api_token_set: + type: boolean + default_project_uuid: + type: string + nullable: true + default_environment_uuid: + type: string + nullable: true + default_environment_name: + type: string + nullable: true + default_server_uuid: + type: string + nullable: true + default_destination_uuid: + type: string + nullable: true + status: + type: string + last_checked_at: + type: string + nullable: true + last_error: + type: string + nullable: true + + SuperuserCoolifyPlacementResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + generated_at: + type: string + instance: + $ref: '#/components/schemas/SuperuserCoolifyInstance' + servers: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyPlacementServer' + projects: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyPlacementProject' + environments: + type: array + items: + $ref: '#/components/schemas/SuperuserCoolifyPlacementEnvironment' + destination_discovery_supported: + type: boolean + errors: + type: object + additionalProperties: true + + SuperuserCoolifyPlacementServer: + type: object + properties: + id: + type: integer + nullable: true + uuid: + type: string + name: + type: string + description: + type: string + ip: + type: string + user: + type: string + port: + type: integer + nullable: true + proxy_type: + type: string + swarm_cluster: + type: string + is_reachable: + type: boolean + nullable: true + is_usable: + type: boolean + nullable: true + + SuperuserCoolifyPlacementProject: + type: object + properties: + id: + type: integer + nullable: true + uuid: + type: string + name: + type: string + description: + type: string + + SuperuserCoolifyPlacementEnvironment: + type: object + properties: + id: + type: integer + nullable: true + uuid: + type: string + name: + type: string + description: + type: string + project_id: + type: integer + nullable: true + project_uuid: + type: string + project_name: + type: string + + SuperuserCoolifyTarget: + type: object + properties: + id: + type: integer + instance_id: + type: integer + instance_label: + type: string + kind: + type: string + enum: [database, redis, minio] + label: + type: string + role: + type: string + enum: [replica] + server_uuid: + type: string + nullable: true + project_uuid: + type: string + nullable: true + environment_uuid: + type: string + nullable: true + environment_name: + type: string + nullable: true + destination_uuid: + type: string + nullable: true + resource_uuid: + type: string + nullable: true + resource_type: + type: string + resource_name: + type: string + nullable: true + deployment_status: + type: string + availability_state: + type: string + enum: [protected, degraded, failover_ready, failover_blocked, failing_over, destructive_action_required] + last_reconcile_status: + type: string + nullable: true + replication: + type: object + additionalProperties: true + + SuperuserCoolifyInstanceCreateRequest: + type: object + required: + - label + - base_url + - api_token + properties: + label: + type: string + base_url: + type: string + api_token: + type: string + format: password + + SuperuserCoolifyTargetCreateRequest: + allOf: + - $ref: '#/components/schemas/SuperuserReplicationHostCreateRequest' + - type: object + required: + - kind + - host + properties: + instance_id: + type: integer + kind: + type: string + enum: [database, redis, minio] + role: + type: string + enum: [replica] + default: replica + server_uuid: + type: string + project_uuid: + type: string + environment_uuid: + type: string + environment_name: + type: string + destination_uuid: + type: string + deploy: + type: boolean + default: false + + SuperuserReplicationComposeTemplateResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/SuperuserReplicationComposeTemplate' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + SuperuserReplicationComposeTemplate: + type: object + properties: + kind: + type: string + enum: [database, redis, minio] + engine: + type: string + enum: [mariadb, redis, minio] + role: + type: string + enum: [primary, replica] + service_name: + type: string + host_port: + type: integer + console_port: + type: integer + nullable: true + server_id: + type: integer + nullable: true + compose: + type: string + description: Complete docker-compose.yml content with secret environment placeholders. + env: + type: string + description: Example .env content for the placeholders used by compose. + seed_command: + type: string + description: One-time MariaDB seed command to initialize a replica from the primary before provisioning. + credentials: + $ref: '#/components/schemas/SuperuserReplicationGeneratedCredentials' + steps: + type: array + items: + type: string + + SuperuserReplicationGeneratedCredentials: + type: object + properties: + label: + type: string + host: + type: string + port: + type: integer + endpoint: + type: string + scheme: + type: string + enum: [http, https] + buckets: + type: array + items: + type: string + console_port: + type: integer + replication_transfer_limit: + type: string + nullable: true + space_headroom_percent: + type: number + format: float + database: + oneOf: + - type: string + - type: integer + username: + type: string + password: + type: string + format: password + admin_username: + type: string + admin_password: + type: string + format: password + replication_username: + type: string + replication_password: + type: string + format: password + ssl_mode: + type: string + allow_preseeded_replica: + type: boolean + + SuperuserReplicationSummary: + type: object + properties: + generated_at: + type: string + format: date-time + database: + $ref: '#/components/schemas/SuperuserReplicationKindSummary' + redis: + $ref: '#/components/schemas/SuperuserReplicationKindSummary' + minio: + $ref: '#/components/schemas/SuperuserReplicationKindSummary' + write_freeze: + type: object + additionalProperties: true + + SuperuserReplicationKindSummary: + type: object + properties: + primary: + $ref: '#/components/schemas/SuperuserReplicationHost' + nullable: true + hosts: + type: array + items: + $ref: '#/components/schemas/SuperuserReplicationHost' + replication: + $ref: '#/components/schemas/SuperuserReplicationStatus' + + SuperuserReplicationStatus: + type: object + properties: + status: + type: string + enum: [ok, degraded, down, not_configured] + min_percent: + type: number + format: float + average_percent: + type: number + format: float + replicas: + type: array + items: + $ref: '#/components/schemas/SuperuserReplicationHost' + blockers: + type: array + items: + type: string + + SuperuserReplicationHost: + type: object + properties: + id: + type: integer + kind: + type: string + enum: [database, redis, minio] + label: + type: string + host: + type: string + port: + type: integer + database: + oneOf: + - type: string + - type: integer + nullable: true + endpoint: + type: string + nullable: true + scheme: + type: string + enum: [http, https] + nullable: true + buckets: + type: array + items: + type: string + console_port: + type: integer + nullable: true + replication_transfer_limit: + type: string + nullable: true + description: MinIO replication and seed bandwidth cap passed to mc --limit-upload/--limit-download, for example 25Mi. Use 0 to disable. + space_headroom_percent: + type: number + format: float + nullable: true + role: + type: string + enum: [primary, replica, inactive] + status: + type: string + replication_source_id: + type: integer + nullable: true + replication_percent: + type: number + format: float + last_status: + type: object + additionalProperties: true + credential_summary: + type: object + additionalProperties: true + deployment_provider: + type: string + enum: [manual, coolify] + coolify: + type: object + nullable: true + additionalProperties: true + availability_state: + type: string + nullable: true + enum: [protected, degraded, failover_ready, failover_blocked, failing_over, destructive_action_required] + + SuperuserReplicationHostCreateRequest: + type: object + required: + - host + - port + properties: + label: + type: string + host: + type: string + description: Hostname or MinIO endpoint. MinIO hosts may include http(s) scheme; the backend stores the host without scheme. + endpoint: + type: string + description: Optional MinIO endpoint alias for host. + port: + type: integer + database: + oneOf: + - type: string + - type: integer + username: + type: string + description: Database/Redis username or MinIO access key. + password: + type: string + format: password + description: Database/Redis password or MinIO secret key. + scheme: + type: string + enum: [http, https] + description: MinIO endpoint scheme. + buckets: + type: array + items: + type: string + description: MinIO buckets to replicate. + console_port: + type: integer + description: Optional MinIO console port for UI display. + replication_transfer_limit: + type: string + description: Optional MinIO replication and seed bandwidth cap. Defaults to 25Mi. Use 0 to disable. + space_headroom_percent: + type: number + format: float + description: MinIO free-space headroom required before provisioning. Defaults to 20. + admin_username: + type: string + admin_password: + type: string + format: password + replication_username: + type: string + replication_password: + type: string + format: password + ssl_mode: + type: string + deployment_provider: + type: string + enum: [manual, coolify] + options: + type: object + properties: + allow_preseeded_replica: + type: boolean + description: Allow configuring replication when the replica has already been safely seeded outside the orchestrator. Required for MariaDB, which does not support MySQL Clone. + scheme: + type: string + enum: [http, https] + buckets: + type: array + items: + type: string + console_port: + type: integer + replication_transfer_limit: + type: string + space_headroom_percent: + type: number + format: float + additionalProperties: true + + SuperuserReplicationHostRenameRequest: + type: object + required: + - label + properties: + label: + type: string + minLength: 1 + maxLength: 128 + + SuperuserReplicationUnsavedCredentialTestRequest: + allOf: + - $ref: '#/components/schemas/SuperuserReplicationHostCreateRequest' + - type: object + required: + - kind + properties: + kind: + type: string + enum: [database, databases, mysql, redis, minio, s3, object-storage, object_storage] + role: + type: string + enum: [primary, replica] + + SuperuserReplicationComposeTemplateRequest: + type: object + properties: + kind: + type: string + enum: [database, databases, mysql, redis, minio, s3, object-storage, object_storage] + default: database + role: + type: string + enum: [primary, replica] + default: replica + service_name: + type: string + volume_name: + type: string + image: + type: string + database: + type: string + description: MariaDB database name to create on first startup. + username: + type: string + description: MariaDB application username to create on first startup. + password: + type: string + format: password + description: Optional application password to reuse instead of generating one. + admin_password: + type: string + format: password + description: Optional MariaDB root password to reuse instead of generating one. + replication_username: + type: string + description: Replication username to place in generated credentials. + replication_password: + type: string + format: password + description: Optional replication password to reuse instead of generating one. + host_port: + type: integer + minimum: 1 + maximum: 65535 + server_id: + type: integer + minimum: 1 + description: MariaDB server-id. Must be unique across the primary and replicas. + primary_host: + type: string + description: Redis primary host used when generating a Redis replica template. + primary_port: + type: integer + minimum: 1 + maximum: 65535 + description: Redis primary port used when generating a Redis replica template. + primary_password: + type: string + format: password + description: Redis primary password used in the generated Redis replica .env file. If omitted, the .env keeps the value blank for manual entry. + primary_username: + type: string + description: Optional Redis primary ACL username used in the generated Redis replica .env file. Leave blank or default for the default Redis user. + buckets: + type: array + items: + type: string + description: MinIO buckets to create, version, and replicate. + console_port: + type: integer + minimum: 1 + maximum: 65535 + description: MinIO console port exposed by the generated compose service. + replication_transfer_limit: + type: string + description: MinIO replication and seed bandwidth cap included in generated credentials. Defaults to 25Mi. Use 0 to disable. + SuperuserSystemStatusPayload: type: object properties: @@ -11582,6 +14537,8 @@ components: error: type: string nullable: true + replication: + $ref: '#/components/schemas/SuperuserReplicationStatus' SuperuserMinioDependencyStatus: type: object @@ -11616,6 +14573,8 @@ components: error: type: string nullable: true + replication: + $ref: '#/components/schemas/SuperuserReplicationStatus' SuperuserModuleStatus: type: object @@ -12052,7 +15011,7 @@ components: type: object properties: module: { type: string, enum: [economic] } - variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber, transactionDraftCustomerNumber] } + variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber, transactionDraftCustomerNumber, defaultDepartmentId] } type: { type: string, enum: [string, int] } value: oneOf: @@ -13476,6 +16435,140 @@ components: - meta - includes + EconomicTransferQueueMonitorResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + jobs: + type: array + items: + $ref: '#/components/schemas/EconomicTransferQueueJob' + counts: + type: object + properties: + queued: + type: integer + minimum: 0 + in_progress: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + completed: + type: integer + minimum: 0 + total: + type: integer + minimum: 0 + required: + - queued + - in_progress + - failed + - completed + - total + progress_percent: + type: integer + minimum: 0 + maximum: 100 + limit: + type: integer + minimum: 1 + maximum: 100 + required: + - jobs + - counts + - progress_percent + - limit + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueDismissResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + job: + $ref: '#/components/schemas/EconomicTransferQueueJob' + required: + - message + - job + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueDismissTerminalResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + dismissed_count: + type: integer + minimum: 0 + required: + - message + - dismissed_count + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + CollectedInvoiceEconomicCompareResponse: type: object description: Result of comparing a collected invoice with its E-conomic counterpart @@ -14591,6 +17684,287 @@ components: enum: - MACHINE + SelfserveStudioNode: + type: object + required: [id, position, data] + properties: + id: + type: string + type: + type: string + nullable: true + position: + type: object + required: [x, y] + properties: + x: { type: number } + y: { type: number } + data: + type: object + additionalProperties: true + properties: + kind: + type: string + enum: [question, condition, rule, task, lane, machine_type, vehicle_type, edge_gateway, relay_binding, relay, runtime_checkpoint] + object_id: + oneOf: + - type: integer + - type: string + nullable: true + label: + type: string + raw: + type: object + additionalProperties: true + SelfserveStudioEdge: + type: object + required: [id, source, target] + properties: + id: { type: string } + source: { type: string } + target: { type: string } + type: { type: string, nullable: true } + label: { type: string, nullable: true } + data: + type: object + additionalProperties: true + SelfserveStudioLayout: + type: object + properties: + nodes: + type: object + additionalProperties: + type: object + properties: + x: { type: number } + y: { type: number } + viewport: + type: object + additionalProperties: true + runtime_affecting: + type: boolean + enum: [false] + SelfserveStudioValidation: + type: object + properties: + valid: + type: boolean + errors: + type: array + items: { type: string } + warnings: + type: array + items: { type: string } + items: + type: array + items: + type: object + properties: + severity: + type: string + enum: [error, warning] + message: + type: string + stats: + type: object + additionalProperties: true + validated_at: + type: string + format: date-time + SelfserveConfigVersion: + type: object + properties: + id: { type: integer } + department_id: { type: integer } + status: + type: string + enum: [DRAFT, PUBLISHED, ARCHIVED] + version_number: { type: integer } + config: + $ref: '#/components/schemas/SelfserveStudioV2Config' + validation_result: + $ref: '#/components/schemas/SelfserveStudioValidation' + source_version_id: { type: integer, nullable: true } + created_by: { type: integer, nullable: true } + published_at: { type: string, nullable: true } + created_at: { type: string, nullable: true } + updated_at: { type: string, nullable: true } + SelfserveStudioV2Config: + type: object + required: [schema_version, questions, conditions, rules, tasks] + properties: + schema_version: + type: integer + enum: [2] + department_id: + type: integer + questions: + type: array + items: + type: object + additionalProperties: true + conditions: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioV2Condition' + rules: + type: array + description: Empty in schema_version 2; retained only for backward-compatible payload shape. + maxItems: 0 + items: + type: object + tasks: + type: array + items: + type: object + additionalProperties: true + v2_meta: + type: object + additionalProperties: true + migration_issues: + type: array + items: + type: object + additionalProperties: true + SelfserveStudioV2Condition: + type: object + required: [id, expression] + properties: + id: + type: integer + name: + type: string + description: + type: string + nullable: true + expression: + $ref: '#/components/schemas/SelfserveStudioV2Expression' + additionalProperties: true + SelfserveStudioV2Expression: + oneOf: + - $ref: '#/components/schemas/SelfserveStudioV2ExpressionGroup' + - $ref: '#/components/schemas/SelfserveStudioV2ExpressionPredicate' + SelfserveStudioV2ExpressionGroup: + type: object + required: [type, operator, children] + properties: + type: + type: string + enum: [group] + operator: + type: string + enum: [ALL, ANY] + children: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioV2Expression' + SelfserveStudioV2ExpressionPredicate: + type: object + required: [type, subject_type, subject_id, operator] + properties: + type: + type: string + enum: [predicate] + subject_type: + type: string + enum: [question, condition] + subject_id: + type: integer + operator: + type: string + enum: [IS_TRUE, IS_FALSE, IS_SET, IS_TRUE_OR_NOT_SET, IS_FALSE_OR_NOT_SET] + SelfserveStudioGraph: + type: object + required: [nodes, edges, lookups, validation, layout, versions, simulator_defaults, gateway_workspace, permissions] + properties: + nodes: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioNode' + edges: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioEdge' + lookups: + type: object + additionalProperties: true + validation: + $ref: '#/components/schemas/SelfserveStudioValidation' + layout: + $ref: '#/components/schemas/SelfserveStudioLayout' + versions: + type: array + items: + $ref: '#/components/schemas/SelfserveConfigVersion' + active_config: + allOf: + - $ref: '#/components/schemas/SelfserveStudioV2Config' + nullable: true + draft: + type: object + additionalProperties: true + simulator_defaults: + type: object + additionalProperties: true + gateway_workspace: + type: object + additionalProperties: true + permissions: + type: object + additionalProperties: + type: boolean + meta: + type: object + additionalProperties: true + SelfserveStudioGraphOperation: + type: object + properties: + action: + type: string + enum: [create, update, delete, connect, disconnect, reorder, upsert, upsert_path] + entity: + type: string + enum: [question, condition, task, action, path] + description: Standalone rule operations are not accepted for schema_version 2 drafts. + id: + type: integer + nullable: true + source: + type: string + nullable: true + target: + type: string + nullable: true + data: + type: object + additionalProperties: true + items: + type: array + items: + type: object + additionalProperties: true + SelfserveStudioGraphSaveRequest: + type: object + required: [department] + properties: + department: { type: integer } + operations: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioGraphOperation' + nodes: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioNode' + layout: + $ref: '#/components/schemas/SelfserveStudioLayout' + SelfserveStudioLayoutSaveRequest: + type: object + required: [department, layout] + properties: + department: { type: integer } + layout: + $ref: '#/components/schemas/SelfserveStudioLayout' SelfserveMachineType: type: object properties: @@ -14675,6 +18049,14 @@ components: type: string status: type: string + enum: + - PENDING_QUESTIONS + - READY_FOR_MACHINE_START + - MACHINE_NOT_ALLOWED + - MACHINE_RELAY_ENABLED + - MACHINE_STARTED + - COMPLETED + - FORCE_STOPPED allowed: type: boolean machine_relay_enabled: @@ -14753,6 +18135,12 @@ components: type: integer type: type: string + enum: + - SESSION_SYNCED + - MACHINE_RELAY_ENABLED + - MACHINE_START_TRIGGERED + - SESSION_COMPLETED + - SESSION_FORCE_STOPPED payload: type: object additionalProperties: true @@ -14761,6 +18149,430 @@ components: type: string format: date-time + SelfserveStudioSimulationDebug: + type: object + required: [summary, parameters, stages, questions, conditions, rules, tasks, hardware, graph_annotations, recommendations] + properties: + summary: + type: object + additionalProperties: true + parameters: + type: object + additionalProperties: true + stages: + type: array + items: + type: object + additionalProperties: true + questions: + type: array + items: + type: object + additionalProperties: true + conditions: + type: array + items: + type: object + additionalProperties: true + rules: + type: array + items: + type: object + additionalProperties: true + tasks: + type: array + items: + type: object + additionalProperties: true + actions: + type: array + items: + type: object + additionalProperties: true + dynamic_image_buttons: + type: array + items: + type: object + additionalProperties: true + decisions: + type: array + items: + type: object + required: [kind, id, label, state, reason, node_ids, causes] + properties: + kind: + type: string + id: + oneOf: + - type: integer + - type: string + nullable: true + label: + type: string + state: + type: string + reason: + type: string + node_ids: + type: array + items: + type: string + causes: + type: array + items: + type: object + additionalProperties: true + additionalProperties: true + hardware: + type: object + additionalProperties: true + graph_annotations: + type: object + properties: + nodes: + type: object + additionalProperties: + type: object + additionalProperties: true + edges: + type: object + additionalProperties: + type: object + additionalProperties: true + recommendations: + type: array + items: + type: object + additionalProperties: true + + SelfserveStudioSimulationResponse: + allOf: + - $ref: '#/components/schemas/SelfserveVehicleAllowedResponse' + - type: object + properties: + simulator_version: { type: integer } + dry_run: { type: boolean, enum: [true] } + mode: { type: string, enum: [full_dry_run] } + config_source: { type: string, enum: [draft, published] } + debug: + $ref: '#/components/schemas/SelfserveStudioSimulationDebug' + + SelfserveStudioPathOutcomesRequest: + type: object + required: [department] + properties: + department: + type: integer + lane_id: + type: integer + nullable: true + vehicle_type_id: + type: integer + nullable: true + config_source: + type: string + enum: [draft, published] + default: draft + hardware_mode: + type: string + enum: [studio, real, none] + default: studio + include_hardware: + type: boolean + default: true + max_states: + type: integer + minimum: 1 + nullable: true + description: Optional debug cap. Omit for complete path projection. + path_sample_limit: + type: integer + minimum: 1 + nullable: true + description: Optional debug cap for returned path rows. Omit to return every terminal path row. + + SelfserveStudioPathOutcomesResponse: + type: object + required: [scope, summary, outcomes, paths, warnings, truncated, progress] + properties: + scope: + type: object + additionalProperties: true + summary: + type: object + required: [state_count, terminal_path_count, outcome_count, question_count, max_states, path_sample_count] + properties: + state_count: { type: integer } + terminal_path_count: { type: integer } + outcome_count: { type: integer } + question_count: { type: integer } + question_ids: + type: array + items: { type: integer } + max_states: { type: integer } + path_sample_count: { type: integer } + confirmations: + $ref: '#/components/schemas/SelfserveStudioPathConfirmationSummary' + outcomes: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathOutcome' + paths: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathResult' + warnings: + type: array + items: { type: string } + truncated: + type: boolean + progress: + $ref: '#/components/schemas/SelfserveStudioPathProgress' + confirmations: + type: object + properties: + summary: + $ref: '#/components/schemas/SelfserveStudioPathConfirmationSummary' + removed: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathConfirmation' + + SelfserveStudioPathProgress: + type: object + required: [complete, percent, state_count, pending_state_count, terminal_path_count] + properties: + complete: { type: boolean } + percent: + type: integer + minimum: 0 + maximum: 100 + state_count: { type: integer } + pending_state_count: { type: integer } + terminal_path_count: { type: integer } + scenario_index: + type: integer + nullable: true + scenario_count: + type: integer + nullable: true + + SelfserveStudioPathOutcome: + type: object + required: [id, summary, path_count, allowed, services, tasks, signals, sample_chains, node_ids] + properties: + id: { type: string } + summary: { type: string } + path_count: { type: integer } + allowed: { type: boolean } + services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + tasks: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathTask' + signals: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathSignal' + sample_chains: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathSampleChain' + scopes: + type: array + items: + type: object + additionalProperties: true + node_ids: + type: array + items: { type: string } + + SelfserveStudioPathResult: + type: object + required: [id, result, summary, allowed, services, tasks, signals, task_count, signal_count, answers, scope, node_ids] + properties: + id: { type: string } + result: { type: string } + summary: { type: string } + allowed: { type: boolean } + services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + tasks: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathTask' + signals: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathSignal' + task_count: { type: integer } + signal_count: { type: integer } + answers: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathAnswer' + scope: + type: object + additionalProperties: true + node_ids: + type: array + items: { type: string } + path_signature: { type: string } + result_signature: { type: string } + confirmation_status: + type: string + enum: [unconfirmed, confirmed, stale] + confirmed_at: + type: string + nullable: true + confirmed_by: + type: integer + nullable: true + stale_reason: + type: string + nullable: true + + SelfserveStudioPathConfirmationSummary: + type: object + properties: + confirmed: { type: integer } + unconfirmed: { type: integer } + stale: { type: integer } + removed: { type: integer } + total: { type: integer } + + SelfserveStudioPathConfirmationRequest: + type: object + required: [department, path_signature] + properties: + department: { type: integer } + action: + type: string + enum: [confirm, reset, delete, clear] + default: confirm + path_signature: { type: string } + result_signature: + type: string + description: Required when action is confirm. + scope: + type: object + additionalProperties: true + answers: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathAnswer' + result: + type: object + additionalProperties: true + + SelfserveStudioPathConfirmation: + type: object + properties: + id: + type: integer + nullable: true + department_id: { type: integer } + lane_id: + type: integer + nullable: true + vehicle_type_id: + type: integer + nullable: true + config_version_id: + type: integer + nullable: true + config_source: { type: string } + path_signature: { type: string } + result_signature: { type: string } + confirmation_status: + type: string + enum: [unconfirmed, confirmed, stale] + answers: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathAnswer' + result: + type: object + additionalProperties: true + scope: + type: object + additionalProperties: true + confirmed_at: + type: string + nullable: true + confirmed_by: + type: integer + nullable: true + stale_reason: + type: string + nullable: true + + SelfserveStudioPathTask: + type: object + properties: + id: { type: integer } + node_id: { type: string } + label: { type: string } + services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + buttons: + type: array + items: {} + order_priority: { type: integer } + + SelfserveStudioPathSignal: + type: object + properties: + sequence: { type: integer } + runtime_stage: { type: string } + signal_type: { type: string } + relay_role: { type: string } + relay_id: + type: string + nullable: true + target_gateway_label: + type: string + nullable: true + target_binding: + type: string + nullable: true + source: { type: string } + virtual: { type: boolean } + predicted_status: { type: string } + payload: + type: object + additionalProperties: true + skip_block_reason: + type: string + nullable: true + + SelfserveStudioPathSampleChain: + type: object + properties: + scope: + type: object + additionalProperties: true + answers: + type: array + items: + $ref: '#/components/schemas/SelfserveStudioPathAnswer' + + SelfserveStudioPathAnswer: + type: object + properties: + question_id: { type: integer } + question: { type: string } + node_id: { type: string } + answer: { type: boolean } + answer_label: { type: string } + SelfserveVehicleAllowedResponse: type: object properties: @@ -14800,10 +18612,20 @@ components: type: boolean allowed: type: boolean + blocked_reason: + type: string + nullable: true session: allOf: - $ref: '#/components/schemas/SelfserveWashSession' nullable: true + config_source: + type: string + nullable: true + evaluation_trace: + type: object + nullable: true + additionalProperties: true SelfserveWashSummary: type: object @@ -14826,11 +18648,41 @@ components: type: array items: $ref: '#/components/schemas/SelfserveWashTaskSnapshot' + allowed_services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + machine_available: + type: boolean + all_visible_questions_answered: + type: boolean + allowed: + type: boolean events: type: array items: $ref: '#/components/schemas/SelfserveWashEvent' + SelfserveForceStopResponse: + type: object + properties: + lane_id: + type: integer + forced: + type: boolean + bill: + type: boolean + order_id: + type: integer + nullable: true + session: + allOf: + - $ref: '#/components/schemas/SelfserveWashSummary' + nullable: true + runtime_before_reset: + type: object + additionalProperties: true + DepartmentSelfserveVehicleConditionMutationResponse: type: object properties: @@ -15198,6 +19050,8 @@ components: type: integer machine_available: type: boolean + selfserve_enabled: + type: boolean DepartmentCreate: type: object @@ -15266,6 +19120,9 @@ components: machine_type_id: type: integer nullable: true + selfserve_enabled: + type: boolean + default: true status: type: string created_at: @@ -15302,6 +19159,9 @@ components: machine_type_id: type: integer nullable: true + selfserve_enabled: + type: boolean + default: true DepartmentLaneUpdate: type: object @@ -15331,6 +19191,8 @@ components: machine_type_id: type: integer nullable: true + selfserve_enabled: + type: boolean DepartmentGate: type: object diff --git a/services/nginx/app/phpunit.xml b/services/nginx/app/phpunit.xml index e8c05c76..dd363826 100644 --- a/services/nginx/app/phpunit.xml +++ b/services/nginx/app/phpunit.xml @@ -14,6 +14,9 @@ tests/Api + + tests/Legacy + diff --git a/services/nginx/app/resources/edge-gateway-agent/Dockerfile.auto-updater b/services/nginx/app/resources/edge-gateway-agent/Dockerfile.auto-updater index 47bbd462..b85149bc 100644 --- a/services/nginx/app/resources/edge-gateway-agent/Dockerfile.auto-updater +++ b/services/nginx/app/resources/edge-gateway-agent/Dockerfile.auto-updater @@ -3,9 +3,10 @@ FROM ${BASE_IMAGE} RUN set -eux; \ apt-get update; \ - apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose; \ - rm -rf /var/lib/apt/lists/*; \ - php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }' + apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose libcurl4-openssl-dev libsqlite3-dev; \ + docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \ + php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \ + rm -rf /var/lib/apt/lists/* COPY auto-updater.php /usr/local/bin/auto-updater.php diff --git a/services/nginx/app/resources/edge-gateway-agent/Dockerfile.edge-agent b/services/nginx/app/resources/edge-gateway-agent/Dockerfile.edge-agent index a888a98d..ab09f969 100644 --- a/services/nginx/app/resources/edge-gateway-agent/Dockerfile.edge-agent +++ b/services/nginx/app/resources/edge-gateway-agent/Dockerfile.edge-agent @@ -2,7 +2,11 @@ ARG BASE_IMAGE=php:8.2-cli-bookworm FROM ${BASE_IMAGE} RUN set -eux; \ - php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }' + apt-get update; \ + apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \ + docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \ + php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \ + rm -rf /var/lib/apt/lists/* WORKDIR /opt/truckwash-edge-agent diff --git a/services/nginx/app/resources/edge-gateway-agent/Dockerfile.lan-worker b/services/nginx/app/resources/edge-gateway-agent/Dockerfile.lan-worker index 5721d482..e7e916d4 100644 --- a/services/nginx/app/resources/edge-gateway-agent/Dockerfile.lan-worker +++ b/services/nginx/app/resources/edge-gateway-agent/Dockerfile.lan-worker @@ -2,7 +2,11 @@ ARG BASE_IMAGE=php:8.2-cli-bookworm FROM ${BASE_IMAGE} RUN set -eux; \ - php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }' + apt-get update; \ + apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \ + docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \ + php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \ + rm -rf /var/lib/apt/lists/* WORKDIR /opt/truckwash-edge-agent diff --git a/services/nginx/app/resources/edge-gateway-agent/agent.php b/services/nginx/app/resources/edge-gateway-agent/agent.php index 9d1aba5c..136c613a 100644 --- a/services/nginx/app/resources/edge-gateway-agent/agent.php +++ b/services/nginx/app/resources/edge-gateway-agent/agent.php @@ -1016,6 +1016,9 @@ final class TruckwashEdgeAgent private string $controlPlaneStatusPath; private string $stagedUpdatePath; private int $lastHeartbeatAt = 0; + private int $lastMachineSignalPollAt = 0; + private int $lastMachineSignalMonitorRefreshAt = 0; + private ?array $lastControlPlaneResponse = null; private string $agentInstanceId; public function __construct(string $configPath) @@ -1058,6 +1061,7 @@ final class TruckwashEdgeAgent $this->flushOutbox(); $this->pumpBrokerTransport(); $this->heartbeat(); + $this->pollMachineStartSignals(); if ($this->resumePendingOperationCompletion()) { $this->pumpBrokerTransport(); continue; @@ -1280,6 +1284,7 @@ final class TruckwashEdgeAgent } $heartbeatSucceededAt = date('c'); + $this->applyBrokerUrlFromControlPlaneResponse($this->lastControlPlaneResponse); $this->lastHeartbeatAt = time(); $this->recordSuccessfulSync($heartbeatSucceededAt, [ 'last_heartbeat_success_at' => $heartbeatSucceededAt, @@ -1291,6 +1296,171 @@ final class TruckwashEdgeAgent ], JSON_UNESCAPED_SLASHES) . PHP_EOL); } + private function pollMachineStartSignals(): void + { + $interval = max(1, (int)$this->config->get('machineSignalPollIntervalSeconds', 2)); + if ((time() - $this->lastMachineSignalPollAt) < $interval) { + return; + } + $this->lastMachineSignalPollAt = time(); + + $gatewayId = (int)$this->config->get('gatewayId'); + $agentToken = (string)$this->config->get('agentToken'); + if ($gatewayId <= 0 || trim($agentToken) === '') { + return; + } + + foreach ($this->machineSignalMonitors($gatewayId, $agentToken) as $monitor) { + $localIp = trim((string)($monitor['local_ip'] ?? '')); + if ($localIp === '') { + continue; + } + + try { + $component = strtolower(trim((string)($monitor['component'] ?? 'input'))) === 'switch' ? 'switch' : 'input'; + $channel = (int)($monitor['channel'] ?? 0); + $status = $this->machineSignalMonitorStatus($localIp, $channel, $component); + $on = $this->machineSignalOnState($status, $component); + if ($on === null) { + continue; + } + + $stateKey = sprintf( + 'selfserve_machine_signal:%s:%s:%d', + preg_replace('/[^A-Za-z0-9_\-:.]+/', '_', (string)($monitor['relay_id'] ?? 'relay')), + $component, + $channel + ); + $previous = $this->stateStore->getJson($stateKey, null); + $previousOn = is_array($previous) && array_key_exists('on', $previous) ? (bool)$previous['on'] : false; + + if ($on && !$previousOn) { + $event = $component === 'switch' ? 'switch.on' : 'input.toggle_on'; + $payload = [ + 'agent_token' => $agentToken, + 'agent_instance_id' => $this->agentInstanceId, + 'lane_id' => (int)($monitor['lane_id'] ?? 0), + 'relay_id' => (string)($monitor['relay_id'] ?? ''), + 'device_id' => (string)($monitor['device_id'] ?? ''), + 'component' => $component, + 'channel' => $channel, + 'event' => $event, + 'source' => 'edge_gateway_poll', + 'status' => $status, + ]; + $payload[$component === 'switch' ? 'output' : 'state'] = true; + + $this->sendControlPlaneEvent( + '/edge-agent/gateways/' . $gatewayId . '/selfserve/machine-signal', + $payload, + 'machine_signal' + ); + } + + $this->stateStore->setJson($stateKey, [ + 'on' => $on, + 'component' => $component, + 'channel' => $channel, + 'relay_id' => (string)($monitor['relay_id'] ?? ''), + 'updated_at' => date('c'), + ]); + } catch (Throwable $throwable) { + $this->logger->warning('Machine start signal poll failed: ' . $throwable->getMessage()); + } + } + } + + /** + * @return array> + */ + private function machineSignalMonitors(int $gatewayId, string $agentToken): array + { + $cache = $this->stateStore->getJson('selfserve_machine_signal_monitors', []); + if ( + is_array($cache) + && isset($cache['monitors'], $cache['refreshed_at']) + && is_array($cache['monitors']) + && (time() - (int)$cache['refreshed_at']) < 60 + ) { + return array_values(array_filter($cache['monitors'], 'is_array')); + } + + try { + $response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/selfserve/machine-signal-bindings', [ + 'agent_token' => $agentToken, + 'agent_instance_id' => $this->agentInstanceId, + ], 10); + $payload = is_array($response['data'] ?? null) ? (array)$response['data'] : (is_array($response) ? $response : []); + $monitors = is_array($payload['monitors'] ?? null) ? array_values(array_filter((array)$payload['monitors'], 'is_array')) : []; + $this->lastMachineSignalMonitorRefreshAt = time(); + $this->stateStore->setJson('selfserve_machine_signal_monitors', [ + 'refreshed_at' => $this->lastMachineSignalMonitorRefreshAt, + 'monitors' => $monitors, + ]); + + return $monitors; + } catch (Throwable $throwable) { + if (is_array($cache) && isset($cache['monitors']) && is_array($cache['monitors'])) { + return array_values(array_filter($cache['monitors'], 'is_array')); + } + $this->logger->warning('Machine start signal monitor refresh failed: ' . $throwable->getMessage()); + return []; + } + } + + private function machineSignalMonitorStatus(string $localIp, int $channel, string $component): array + { + if ($component === 'input') { + try { + $input = $this->workerHttp->post('/relay/input-status', [ + 'local_ip' => $localIp, + 'channel' => $channel, + ], 5) ?? []; + return [ + 'input_state' => $input['state'] ?? null, + 'input' => $input, + ]; + } catch (Throwable) { + // Fall back to the combined status endpoint below. + } + } + + return $this->workerHttp->post('/relay/status', [ + 'local_ip' => $localIp, + 'channel' => $channel, + 'include_input' => $component === 'input', + ], 8) ?? []; + } + + private function machineSignalOnState(array $status, string $component): ?bool + { + if ($component === 'switch') { + if (array_key_exists('output', $status)) { + return (bool)$status['output']; + } + if (array_key_exists('on', $status)) { + return (bool)$status['on']; + } + if (isset($status['raw']) && is_array($status['raw']) && array_key_exists('output', $status['raw'])) { + return (bool)$status['raw']['output']; + } + + return null; + } + + if (array_key_exists('input_state', $status)) { + return $status['input_state'] === null ? null : (bool)$status['input_state']; + } + if (isset($status['input']) && is_array($status['input']) && array_key_exists('state', $status['input'])) { + return (bool)$status['input']['state']; + } + if (array_key_exists('state', $status)) { + return (bool)$status['state']; + } + + return null; + } + private function processCommandQueue(): void { $gatewayId = (int)$this->config->get('gatewayId'); @@ -2262,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) { @@ -2289,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; diff --git a/services/nginx/app/resources/edge-gateway-agent/lan-worker.php b/services/nginx/app/resources/edge-gateway-agent/lan-worker.php index e3e7ed9e..df542c61 100644 --- a/services/nginx/app/resources/edge-gateway-agent/lan-worker.php +++ b/services/nginx/app/resources/edge-gateway-agent/lan-worker.php @@ -48,18 +48,22 @@ function worker_http_get_json(string $url, int $timeoutSeconds = 8): array return $decoded; } -function worker_fetch_shelly_state(string $localIp, int $channel): array +function worker_fetch_shelly_state(string $localIp, int $channel, bool $includeInput = false): array { if ($localIp === '') { throw new RuntimeException('Missing Shelly IP address'); } + $input = $includeInput ? worker_fetch_shelly_input_state($localIp, $channel) : null; + try { $payload = worker_http_get_json(sprintf('http://%s/rpc/Switch.GetStatus?id=%d', $localIp, $channel)); return [ 'online' => true, 'on' => (bool)($payload['output'] ?? false), 'output' => (bool)($payload['output'] ?? false), + 'input_state' => $input['state'] ?? null, + 'input' => $input, 'raw' => $payload, ]; } catch (Throwable) { @@ -68,11 +72,31 @@ function worker_fetch_shelly_state(string $localIp, int $channel): array 'online' => true, 'on' => (bool)($payload['ison'] ?? false), 'output' => (bool)($payload['ison'] ?? false), + 'input_state' => $input['state'] ?? null, + 'input' => $input, 'raw' => $payload, ]; } } +function worker_fetch_shelly_input_state(string $localIp, int $channel): ?array +{ + if ($localIp === '') { + throw new RuntimeException('Missing Shelly IP address'); + } + + try { + $payload = worker_http_get_json(sprintf('http://%s/rpc/Input.GetStatus?id=%d', $localIp, $channel), 2); + return [ + 'online' => true, + 'state' => (bool)($payload['state'] ?? false), + 'raw' => $payload, + ]; + } catch (Throwable) { + return null; + } +} + function worker_switch_shelly_state(string $localIp, int $channel, bool $on): array { try { @@ -125,7 +149,19 @@ try { if ($method === 'POST' && $path === '/relay/status') { $localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? '')); $channel = (int)($body['channel'] ?? 0); - worker_json_response(200, worker_fetch_shelly_state($localIp, $channel)); + $includeInput = (bool)($body['include_input'] ?? $body['includeInput'] ?? false); + worker_json_response(200, worker_fetch_shelly_state($localIp, $channel, $includeInput)); + return; + } + + if ($method === 'POST' && $path === '/relay/input-status') { + $localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? '')); + $channel = (int)($body['channel'] ?? 0); + $input = worker_fetch_shelly_input_state($localIp, $channel); + if ($input === null) { + throw new RuntimeException('Shelly input status is not available'); + } + worker_json_response(200, $input); return; } diff --git a/services/nginx/app/routes/BrandingRoute.php b/services/nginx/app/routes/BrandingRoute.php index b8983e6a..d959a102 100644 --- a/services/nginx/app/routes/BrandingRoute.php +++ b/services/nginx/app/routes/BrandingRoute.php @@ -11,6 +11,87 @@ class BrandingRoute { use route_t; + private const BRANDING_FIELDS = [ + 'name' => 'string', + 'description' => 'string', + 'cvr' => 'int', + 'address' => 'string', + 'phone_country_code' => 'int', + 'phone' => 'int', + 'email' => 'string', + 'website' => 'string', + 'banner' => 'string', + 'logo' => 'string', + 'favicon' => 'string', + 'signature' => 'string', + ]; + + private function readBrandingPayload(array $requiredFields = []): array + { + global $response; + + $payload = []; + foreach (self::BRANDING_FIELDS as $field => $type) { + if (!self::isParametersSet([$field])) { + continue; + } + + $value = self::getParameter($field); + if ($value === '') { + $value = null; + } + + if ($value === null) { + if (in_array($field, $requiredFields, true)) { + $response->error($field . ' is required', 400); + } + $payload[$field] = null; + continue; + } + + if ($type === 'int') { + if (is_int($value)) { + $payload[$field] = $value; + continue; + } + + if (is_string($value) && preg_match('/^-?\d+$/', $value) === 1) { + $payload[$field] = (int)$value; + continue; + } + + $response->error($field . ' must be an integer', 400); + } + + if (!is_string($value)) { + $response->error($field . ' must be a string', 400); + } + + $payload[$field] = $value; + } + + foreach ($requiredFields as $requiredField) { + if (!array_key_exists($requiredField, $payload)) { + $response->error($requiredField . ' is required', 400); + } + } + + return $payload; + } + + private function applyBrandingPayload(branding_o $branding, array $payload): void + { + foreach ($payload as $field => $value) { + if (!array_key_exists($field, self::BRANDING_FIELDS) || !property_exists($branding, $field)) { + continue; + } + + $branding->{$field}->set($value); + } + + $branding->objectChanged(); + } + public function run(): void { $this->get('/branding', function () { @@ -35,7 +116,7 @@ class BrandingRoute if ($branding->exists()) { // Return the object as an array $response->success( - (new branding_o())->select(self::getParameter('id'))->__toString() + $branding->asArray() ); } else { // Log the incident @@ -77,25 +158,15 @@ class BrandingRoute if ($user) { // Check if the required parameters are set self::requireParameters(['name', 'description', 'cvr']); - // Check if the parameters are of the correct type - self::requireType(self::getParameter('name'), self::TYPE_STRING()); - self::requireType(self::getParameter('description'), self::TYPE_STRING()); - self::requireType(self::getParameter('cvr'), self::TYPE_INT()); // Create the object $branding = new branding_o(); // Add the object - $branding->add( - [ - 'name' => self::getParameter('name'), - 'description' => self::getParameter('description'), - 'cvr' => self::getParameter('cvr') - ] - ); + $branding->add($this->readBrandingPayload(['name', 'description', 'cvr'])); // Log the incident (new logs_o())->add('branding', 'global', 1, $user->id, 'ADD_BRANDING_OPTION', 'User added a branding option'); // Return the object $response->success( - $branding->__toString() + $branding->asArray() ); } else { // Log the incident @@ -125,41 +196,18 @@ class BrandingRoute $branding = new branding_o(); // Select the object $branding->select(self::getParameter('id')); - // Check what the user wants to edit + if (!$branding->exists()) { + $response->error('Invalid id', 400); + } - // Option name - if (self::isParametersSet(['name'])) { - // Check if the parameters are of the correct type - self::requireType(self::getParameter('name'), self::TYPE_STRING()); - // Set the name - $branding->name->set( - (string)self::getParameter('name') - ); - } - // Option description - if (self::isParametersSet(['description'])) { - // Check if the parameters are of the correct type - self::requireType(self::getParameter('description'), self::TYPE_STRING()); - // Set the description - $branding->description->set( - (string)self::getParameter('description') - ); - } - // Option cvr - if (self::isParametersSet(['cvr'])) { - // Check if the parameters are of the correct type - self::requireType(self::getParameter('cvr'), self::TYPE_INT()); - // Set the cvr value - $branding->cvr->set( - (int)self::getParameter('cvr') - ); - } + $payload = $this->readBrandingPayload(); + $this->applyBrandingPayload($branding, $payload); // Log the incident (new logs_o())->add('branding', 'global', 1, $user->id, 'EDIT_BRANDING_OPTION', 'User edited a branding option'); // Return the object $response->success( - $branding->__toString() + $branding->asArray() ); } else { // Log the incident @@ -173,4 +221,4 @@ class BrandingRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index 1cf90a8e..e9fa78fd 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -3,9 +3,11 @@ namespace routes; use classes\authentication; +use classes\economic; use classes\economic_transfer_queue; use classes\economic_v2_distribution_service; use classes\economic_v2_versioning_service; +use classes\invoice_period_flag_service; use classes\invoicing_period_utils; use classes\slack; use Exception; @@ -34,6 +36,16 @@ class InvoicingPeriodRoute */ private static array $departmentExcludedFromInvoicingCache = []; + private static ?bool $collectedOrderInvoicesHasDeletedAtColumn = null; + + /** + * Local-only booked status caches used by the period response. + * The period endpoint must not call e-conomic for each order. + * @var array + */ + private static array $periodOrderBookedCache = []; + private static array $periodInvoiceCollectionBookedCache = []; + /** * @throws Exception */ @@ -59,6 +71,18 @@ class InvoicingPeriodRoute return self::$departmentExcludedFromInvoicingCache[$departmentId]; } + private static function getLocalCustomerName(int $customerNumber): string + { + $names = (new users_o())->getCustomerNames([$customerNumber], false); + return (string)($names[$customerNumber] ?? 'Unknown Customer'); + } + + private static function getEconomicFallbackDepartmentId(): int + { + $department_id = (new economic())->getDefaultDistributionDepartmentId(); + return $department_id > 0 ? $department_id : economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID; + } + /** * Slack summaries are expensive on request latency, so they are opt-in. * Enable with query param `sendSlackSummary=1` or env `INVOICING_PERIOD_SEND_SLACK_SUMMARY=true`. @@ -105,6 +129,80 @@ class InvoicingPeriodRoute ]; } + /** + * @return int[]|null + */ + private function getOptionalCustomerNumbersParameter(): ?array + { + if (!self::isParametersSet(['customerNumbers'])) { + return null; + } + + return self::normalizeCustomerNumbers(self::getParameter('customerNumbers')); + } + + /** + * @return int[] + */ + private static function normalizeCustomerNumbers(mixed $customerNumbers): array + { + if ($customerNumbers === null || $customerNumbers === '') { + return []; + } + + $rawValues = is_array($customerNumbers) + ? $customerNumbers + : explode(',', (string)$customerNumbers); + + $normalized = []; + foreach ($rawValues as $value) { + $parsed = (int)trim((string)$value); + if ($parsed < 1) { + continue; + } + $normalized[$parsed] = $parsed; + } + + return array_values($normalized); + } + + /** + * @param int[]|null $onlyCustomerNumbers + * @return int[] + */ + private static function filterCustomerNumbers(array $customerNumbers, ?array $onlyCustomerNumbers = null): array + { + $customerNumbers = self::normalizeCustomerNumbers($customerNumbers); + if ($onlyCustomerNumbers === null) { + return $customerNumbers; + } + + $allowed = array_fill_keys(self::normalizeCustomerNumbers($onlyCustomerNumbers), true); + if (empty($allowed)) { + return []; + } + + return array_values(array_filter($customerNumbers, static function (int $customerNumber) use ($allowed): bool { + return isset($allowed[$customerNumber]); + })); + } + + /** + * @param array> $customers + * @return array> + */ + private static function indexCustomersByNumber(array $customers): array + { + $customersByNumber = []; + foreach ($customers as $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + if ($customerNumber > 0) { + $customersByNumber[$customerNumber] = $customer; + } + } + return $customersByNumber; + } + /** * Response cache TTL (seconds) for v2 distribution endpoints. * Set `INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL` to override. @@ -183,6 +281,539 @@ class InvoicingPeriodRoute return $collective_results; } + private static function jsonFragment(mixed $value): string + { + $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return is_string($json) ? $json : 'null'; + } + + private static function streamInvoicingPeriodResponse(array $period): void + { + global $response; + + header('Content-Type: application/json; charset=utf-8'); + http_response_code(200); + + echo '{"success":true,"data":{'; + echo '"dateFrom":' . self::jsonFragment($period['dateFrom'] ?? null); + echo ',"dateTo":' . self::jsonFragment($period['dateTo'] ?? null); + echo ',"types":{'; + + $types = is_array($period['types'] ?? null) ? $period['types'] : []; + $firstType = true; + foreach ($types as $typeName => $customers) { + if (!$firstType) { + echo ','; + } + $firstType = false; + echo self::jsonFragment((string)$typeName) . ':['; + + $firstCustomer = true; + foreach ((array)$customers as $customer) { + if (!$firstCustomer) { + echo ','; + } + $firstCustomer = false; + echo self::jsonFragment($customer); + } + echo ']'; + } + + echo '}'; + foreach ($period as $key => $value) { + if (in_array((string)$key, ['dateFrom', 'dateTo', 'types'], true)) { + continue; + } + echo ',' . self::jsonFragment((string)$key) . ':' . self::jsonFragment($value); + } + echo '}'; + echo ',"meta":' . self::jsonFragment($response->get_meta()); + echo ',"includes":' . self::jsonFragment($response->get_includes()); + echo '}'; + exit; + } + + private static function periodTypeNames(): array + { + return [ + 'all', + 'vehicle_subscriptions', + 'fixed_pricing', + 'tank_cleaning', + 'special_arrangements', + 'invoice_per_order', + 'possible_duplicates', + ]; + } + + private static function getPeriodPaginationOptionsFromRequest(): ?array + { + global $response; + + $paginationKeys = [ + 'periodView', + 'page', + 'limit', + 'search', + 'includeRequiresAction', + 'includeBooked', + ]; + + $isPaginatedRequest = false; + foreach ($paginationKeys as $key) { + if ($response->isRequestParameterSet($key)) { + $isPaginatedRequest = true; + break; + } + } + + if (!$isPaginatedRequest) { + return null; + } + + return self::normalizePeriodPaginationOptions($response->getAllRequestParameters()); + } + + private static function normalizePeriodPaginationOptions(array $parameters): array + { + $allowedViews = array_fill_keys(self::periodTypeNames(), true); + $periodView = trim((string)($parameters['periodView'] ?? 'all')); + if ($periodView === '' || !isset($allowedViews[$periodView])) { + $periodView = 'all'; + } + + $page = (int)($parameters['page'] ?? 1); + if ($page < 1) { + $page = 1; + } + + $limitParameter = strtolower(trim((string)($parameters['limit'] ?? '100'))); + if ($limitParameter === 'all') { + $limit = 'all'; + } else { + $limit = (int)$limitParameter; + if ($limit < 1) { + $limit = 100; + } + $limit = min(500, $limit); + } + + return [ + 'periodView' => $periodView, + 'page' => $page, + 'limit' => $limit, + 'search' => trim((string)($parameters['search'] ?? '')), + 'flagTab' => trim((string)($parameters['flagTab'] ?? 'all')), + 'includeRequiresAction' => self::parsePeriodBooleanOption( + $parameters['includeRequiresAction'] ?? null, + true + ), + 'includeBooked' => self::parsePeriodBooleanOption($parameters['includeBooked'] ?? null, true), + ]; + } + + private static function parsePeriodBooleanOption(mixed $value, bool $default): bool + { + if ($value === null || $value === '') { + return $default; + } + + if (is_bool($value)) { + return $value; + } + + $normalized = strtolower(trim((string)$value)); + if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { + return false; + } + if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + + return $default; + } + + private static function applyPeriodPagination(array $period, array $options): array + { + $types = is_array($period['types'] ?? null) ? $period['types'] : []; + $types = self::ensurePeriodTypeKeys($types); + $types = self::enrichPeriodCustomerMetaFromTypes($types); + $types = self::filterPeriodTypesBySearch($types, (string)($options['search'] ?? '')); + $types = self::filterPeriodTypesByVisibility( + $types, + (bool)($options['includeRequiresAction'] ?? true), + (bool)($options['includeBooked'] ?? true) + ); + + $periodView = (string)($options['periodView'] ?? 'all'); + if (!array_key_exists($periodView, $types)) { + $periodView = 'all'; + } + + $typeCounts = self::summarizePeriodTypes($types); + + if (!empty($options['flagTab'])) { + $types = self::filterPeriodTypesByFlagTab($types, (string)$options['flagTab']); + } + + $total = count($types[$periodView] ?? []); + $limit = $options['limit'] ?? 100; + $isAllLimit = $limit === 'all'; + $perPage = $isAllLimit ? 'all' : max(1, min(500, (int)$limit)); + $totalPages = $isAllLimit || $total === 0 ? 1 : (int)ceil($total / $perPage); + $page = $isAllLimit ? 1 : max(1, (int)($options['page'] ?? 1)); + $page = min($page, $totalPages); + + $pagedTypes = array_fill_keys(array_keys($types), []); + if ($isAllLimit) { + $pagedTypes[$periodView] = array_values($types[$periodView] ?? []); + } else { + $offset = ($page - 1) * $perPage; + $pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage); + } + + $period['types'] = $pagedTypes; + $period['type_counts'] = $typeCounts; + $period['type_totals'] = self::summarizePeriodTypeTotals($types); + + return [ + 'period' => $period, + 'pagination' => [ + 'page' => $page, + 'per_page' => $perPage, + 'total' => $total, + 'total_pages' => $totalPages, + 'search' => (string)($options['search'] ?? ''), + 'filters' => [ + 'includeRequiresAction' => (bool)($options['includeRequiresAction'] ?? true), + 'includeBooked' => (bool)($options['includeBooked'] ?? true), + ], + 'order' => [ + 'field' => 'customer_name', + 'direction' => 'asc', + ], + ], + ]; + } + + private static function filterPeriodTypesByFlagTab(array $types, string $flagTab): array + { + if (in_array($flagTab, ['all', 'filters', ''], true)) { + return $types; + } + + foreach ($types as $viewName => $entries) { + $types[$viewName] = array_values(array_filter( + is_array($entries) ? $entries : [], + static function (array $customer) use ($flagTab): bool { + $hasManual = false; + $hasAutomatic = false; + if (is_array($customer['flags'] ?? null)) { + foreach ($customer['flags'] as $flag) { + if (!empty($flag['order_id']) || !empty($flag['invoice_collection_id'])) { + continue; + } + if ($flag['is_manual'] ?? ($flag['source'] ?? '') === 'manual') { + $hasManual = true; + } else { + $hasAutomatic = true; + } + } + } + + $tab = 'none'; + if ($hasManual) { + $tab = 'red'; + } elseif ($hasAutomatic) { + $tab = 'yellow'; + } + + return $tab === $flagTab; + } + )); + } + + return $types; + } + + private static function ensurePeriodTypeKeys(array $types): array + { + foreach (self::periodTypeNames() as $typeName) { + if (!array_key_exists($typeName, $types) || !is_array($types[$typeName])) { + $types[$typeName] = []; + } + } + + return $types; + } + + private static function enrichPeriodCustomerMetaFromTypes(array $types): array + { + $metaByCustomerNumber = []; + foreach (['fixed_pricing', 'vehicle_subscriptions'] as $typeName) { + foreach (($types[$typeName] ?? []) as $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + if ($customerNumber < 1) { + continue; + } + $meta = is_array($customer['meta'] ?? null) ? $customer['meta'] : []; + if ($meta === []) { + continue; + } + $metaByCustomerNumber[$customerNumber] = array_merge( + $metaByCustomerNumber[$customerNumber] ?? [], + $meta + ); + } + } + + if ($metaByCustomerNumber === []) { + return $types; + } + + foreach ($types as $typeName => $customers) { + foreach ($customers as $index => $customer) { + $customerNumber = (int)($customer['customer_number'] ?? 0); + if ($customerNumber < 1 || !isset($metaByCustomerNumber[$customerNumber])) { + continue; + } + + $types[$typeName][$index]['meta'] = array_merge( + is_array($customer['meta'] ?? null) ? $customer['meta'] : [], + $metaByCustomerNumber[$customerNumber] + ); + } + } + + return $types; + } + + private static function filterPeriodTypesBySearch(array $types, string $search): array + { + $search = self::normalizePeriodSearchTerm($search); + if ($search === '') { + return $types; + } + + foreach ($types as $typeName => $customers) { + $types[$typeName] = array_values(array_filter( + is_array($customers) ? $customers : [], + static fn(array $customer): bool => self::periodCustomerMatchesSearch($customer, $search) + )); + } + + return $types; + } + + private static function filterPeriodTypesByVisibility( + array $types, + bool $includeRequiresAction, + bool $includeBooked + ): array { + foreach ($types as $typeName => $customers) { + $types[$typeName] = array_values(array_filter( + is_array($customers) ? $customers : [], + static function (array $customer) use ($includeRequiresAction, $includeBooked): bool { + if (!$includeRequiresAction && (bool)($customer['requires_action'] ?? false)) { + return false; + } + + if ( + !$includeBooked + && !((bool)($customer['requires_action'] ?? false)) + && self::areAllPeriodCustomerTransactionsBooked($customer) + ) { + return false; + } + + return true; + } + )); + } + + return $types; + } + + private static function periodCustomerMatchesSearch(array $customer, string $search): bool + { + $values = [ + $customer['customer_number'] ?? '', + $customer['customer_name'] ?? '', + ]; + + foreach (($customer['transactions'] ?? []) as $transaction) { + if (!is_array($transaction)) { + continue; + } + foreach (['id', 'reference', 'po', 'notes', 'reg_1', 'reg_2', 'reg_3'] as $field) { + $values[] = $transaction[$field] ?? ''; + } + } + + foreach ($values as $value) { + if (str_contains(self::normalizePeriodSearchTerm((string)$value), $search)) { + return true; + } + } + + return false; + } + + private static function normalizePeriodSearchTerm(string $value): string + { + return mb_strtolower(trim($value), 'UTF-8'); + } + + private static function areAllPeriodCustomerTransactionsBooked(array $customer): bool + { + $transactions = is_array($customer['transactions'] ?? null) ? $customer['transactions'] : []; + foreach ($transactions as $transaction) { + if (!is_array($transaction) || (bool)($transaction['booked'] ?? false) !== true) { + return false; + } + } + + return true; + } + + private static function summarizePeriodTypes(array $types): array + { + $counts = []; + foreach ($types as $typeName => $customers) { + $counts[$typeName] = self::summarizePeriodType(is_array($customers) ? $customers : []); + } + + return $counts; + } + + private static function summarizePeriodTypeTotals(array $types): array + { + $totals = []; + foreach ($types as $typeName => $customers) { + $totals[$typeName] = self::summarizePeriodTypeTotalsForCustomers( + is_array($customers) ? $customers : [] + ); + } + + return $totals; + } + + private static function summarizePeriodTypeTotalsForCustomers(array $customers): array + { + $total = 0.0; + $booked = 0.0; + + foreach ($customers as $customer) { + if (!is_array($customer)) { + continue; + } + + $total += self::getPeriodCustomerTotalAmount($customer); + $booked += self::sumPeriodCustomerTransactions($customer, true); + } + + return [ + 'total' => $total, + 'booked' => $booked, + 'not_booked' => $total - $booked, + ]; + } + + private static function getPeriodCustomerTotalAmount(array $customer): float + { + $fixedPrice = $customer['meta']['fixed_pricing']['price'] ?? null; + if ($fixedPrice !== null && $fixedPrice !== '') { + return (float)$fixedPrice; + } + + return self::sumPeriodCustomerTransactions($customer, false); + } + + private static function sumPeriodCustomerTransactions(array $customer, bool $bookedOnly): float + { + $total = 0.0; + $transactions = is_array($customer['transactions'] ?? null) ? $customer['transactions'] : []; + foreach ($transactions as $transaction) { + if (!is_array($transaction)) { + continue; + } + if ((bool)($transaction['excluded'] ?? false)) { + continue; + } + if ($bookedOnly && (bool)($transaction['booked'] ?? false) !== true) { + continue; + } + + $total += (float)($transaction['amount'] ?? $transaction['net_amount'] ?? 0); + } + + return $total; + } + + private static function summarizePeriodType(array $customers): array + { + $requiresAction = 0; + $draft = 0; + $manualFlags = 0; + $automaticFlags = 0; + + foreach ($customers as $customer) { + if ((bool)($customer['requires_action'] ?? false)) { + $requiresAction++; + } + if (($customer['draft']['is_action_blocked'] ?? false) === true) { + $draft++; + } + + $flagCounts = self::getActivePeriodFlagCounts($customer); + if ($flagCounts['manual'] > 0) { + $manualFlags++; + } + if ($flagCounts['automatic'] > 0) { + $automaticFlags++; + } + } + + return [ + 'requires_action' => $requiresAction, + 'draft' => $draft, + 'manual_flags' => $manualFlags, + 'automatic_flags' => $automaticFlags, + 'completed' => max(0, count($customers) - $requiresAction - $draft), + 'total' => count($customers), + ]; + } + + private static function getActivePeriodFlagCounts(array $customer): array + { + $manual = 0; + $automatic = 0; + if (is_array($customer['flags'] ?? null)) { + foreach ($customer['flags'] as $flag) { + if (!is_array($flag) || (string)($flag['status'] ?? 'active') !== 'active') { + continue; + } + if (($flag['source'] ?? null) === 'manual') { + $manual++; + } elseif (($flag['source'] ?? null) === 'automatic') { + $automatic++; + } + } + + return [ + 'manual' => $manual, + 'automatic' => $automatic, + 'total' => $manual + $automatic, + ]; + } + + return [ + 'manual' => (int)($customer['flag_counts']['manual'] ?? 0), + 'automatic' => (int)($customer['flag_counts']['automatic'] ?? 0), + 'total' => (int)($customer['flag_counts']['total'] ?? 0), + ]; + } + public function run(): void { $this->get('/superuser/invoicing/period', function () { @@ -196,11 +827,22 @@ class InvoicingPeriodRoute $dateRange = $this->requireAndNormalizeDateRange(); $dateFrom = $dateRange['dateFrom']; $dateTo = $dateRange['dateTo']; + $customerNumbers = $this->getOptionalCustomerNumbersParameter(); // Add date from and date to to the response meta $response->add_meta('date_from', $dateFrom); $response->add_meta('date_to', $dateTo); + if ($customerNumbers !== null) { + $response->add_meta('customer_numbers', $customerNumbers); + } + $paginationOptions = self::getPeriodPaginationOptionsFromRequest(); // Get the invoicing period for the user - $response->success([...self::getInvoicingPeriod($dateFrom, $dateTo)]); + $period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers); + if ($paginationOptions !== null) { + $paginated = self::applyPeriodPagination($period, $paginationOptions); + $period = $paginated['period']; + $response->add_meta('pagination', $paginated['pagination']); + } + self::streamInvoicingPeriodResponse($period); } else { // Log the incident (new logs_o())->add('vehicles', 'global', 1, 0, 'LIST_OWN_VEHICLES', 'No user found, or invalid session'); @@ -210,6 +852,88 @@ class InvoicingPeriodRoute }, [ 'superuser_invoicing_period' => 'Get the invoicing period for superusers', + 'list_invoice_period_flags' => 'List invoice period flags in the period response', + ] + ); + + $this->post('/superuser/invoicing/period/flags', function () { + global $response; + $this->requirePermission('add_invoice_period_flag'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + try { + $flag = (new invoice_period_flag_service())->createManualFlag( + $this->getParametersAsArray(), + (int)$user->id + ); + $response->success($flag); + } catch (\InvalidArgumentException $e) { + $response->error($e->getMessage(), 400); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 500); + } + }, + [ + 'add_invoice_period_flag' => 'Add a manual invoice period flag', + ] + ); + + $this->patch('/superuser/invoicing/period/flags/{id}/status', function () { + global $response; + $this->requirePermission('update_invoice_period_flag_status'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $id = (int)($this->fromRoute('id') ?? 0); + try { + $flag = (new invoice_period_flag_service())->updateManualFlagStatus( + $id, + (string)$this->getParameter('status'), + $this->isParametersSet(['reason']) ? (string)$this->getParameter('reason') : null, + (int)$user->id + ); + $response->success($flag); + } catch (\InvalidArgumentException $e) { + $response->error($e->getMessage(), 400); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 500); + } + }, + [ + 'update_invoice_period_flag_status' => 'Update a manual invoice period flag status', + ] + ); + + $this->post('/superuser/invoicing/period/flags/automatic/status', function () { + global $response; + $this->requirePermission('update_invoice_period_flag_status'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + try { + $flag = (new invoice_period_flag_service())->updateAutomaticFlagStatus( + $this->getParametersAsArray(), + (int)$user->id + ); + $response->success($flag); + } catch (\InvalidArgumentException $e) { + $response->error($e->getMessage(), 400); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 500); + } + }, + [ + 'update_invoice_period_flag_status' => 'Update an automatic invoice period flag status', ] ); @@ -736,7 +1460,7 @@ class InvoicingPeriodRoute 'difference' => $difference, ]; // Send slack alert - $message = "Subscription price distribution mismatch for customer " . $customer['customer_number'] . " (" . (new users_o())->getCustomerName((int)$customer['customer_number']) . ")\n"; + $message = "Subscription price distribution mismatch for customer " . $customer['customer_number'] . " (" . self::getLocalCustomerName((int)$customer['customer_number']) . ")\n"; $message .= "Subscription total: " . $customer['meta']['subscription']['subscription_total'] . "\n"; $message .= "Distribution total: " . $sum_of_distribution . "\n"; $message .= "Difference: " . $difference . "\n"; @@ -787,7 +1511,7 @@ class InvoicingPeriodRoute */ private static function attemptSubscriptionFallbacks(customer_vehicles_o $vehicle, array &$customer): bool { - if (self::debug) echo "Attempting fallbacks for vehicle " . $vehicle->reg->value() . " of customer " . $customer['customer_number'] . " - " . (new users_o())->getCustomerName((int)$customer['customer_number']) . "\n"; + if (self::debug) echo "Attempting fallbacks for vehicle " . $vehicle->reg->value() . " of customer " . $customer['customer_number'] . " - " . self::getLocalCustomerName((int)$customer['customer_number']) . "\n"; // Run the fallback options in order $subscription_price = (int)(new products_o())->select((int)$vehicle->type->value())->getSubscriptionMonthlyPrice(); if (self::divideSubscriptionAcrossCustomerDepartments($customer, $subscription_price)) { @@ -932,12 +1656,17 @@ class InvoicingPeriodRoute if (self::debug) echo "Fallback 3 not applied: No recent transactions found for customer\n"; return false; // No recent transactions found } - // 4. If no departments are found, assign the subscription to the customers default department (e.g., department ID 1). + // 4. If no departments are found, assign the subscription to customer default department + // or fallback to e-conomic default distribution department. private static function useDefaultDepartment(array &$customer, int $subscription_price): bool { if (self::debug) echo "Fallback 4: Using default department\n"; - $default_department_id = (new users_o)->getUserByCustomerNumber((int)$customer['customer_number'])->getDefaultDepartment(); - if (!empty($default_department_id)) { + $customer_default_department_id = (int)(new users_o)->getUserByCustomerNumber((int)$customer['customer_number'])->getDefaultDepartment(); + $default_department_id = $customer_default_department_id > 0 + ? $customer_default_department_id + : self::getEconomicFallbackDepartmentId(); + + if ($default_department_id > 0) { if (!isset($customer['meta']['subscription']['subscription_price_department_distribution'][$default_department_id])) { $customer['meta']['subscription']['subscription_price_department_distribution'][$default_department_id] = 0; } @@ -1027,6 +1756,15 @@ class InvoicingPeriodRoute // Add the transaction amount to the department total $department_totals[$department_id] += $transaction_original_price; } + } else { + $customer_default_department_id = (int)(new users_o())->getUserByCustomerNumber((int)$customer['customer_number'])->getDefaultDepartment(); + $fallback_department_id = $customer_default_department_id > 0 + ? $customer_default_department_id + : self::getEconomicFallbackDepartmentId(); + + if ($fallback_department_id > 0) { + $department_totals[$fallback_department_id] = (float)$customer['meta']['fixed_pricing']['price']; + } } $customer['meta']['fixed_pricing']['original_price'] = $original_price; @@ -1103,33 +1841,36 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getInvoicingPeriod(string $dateFrom, string $dateTo): array + private static function getInvoicingPeriod(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array { //$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo) + $onlyCustomerNumbers = $onlyCustomerNumbers !== null + ? self::normalizeCustomerNumbers($onlyCustomerNumbers) + : null; - $customersWithTransactions = self::debugGetTime(function () use ($dateFrom, $dateTo) { - return self::getCustomersWithTransactions($dateFrom, $dateTo); + $customersWithTransactions = self::debugGetTime(function () use ($dateFrom, $dateTo, $onlyCustomerNumbers) { + return self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); }, 'customers_with_transactions'); $types = []; // Add the customers with transactions to the types array $types['all'] = $customersWithTransactions; - $types['vehicle_subscriptions'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { - return self::getVehicleSubscriptions($dateFrom, $dateTo, $customersWithTransactions); + $types['vehicle_subscriptions'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { + return self::getVehicleSubscriptions($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'vehicle_subscriptions'); - $types['fixed_pricing'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { - return self::getFixedPricing($dateFrom, $dateTo, $customersWithTransactions); + $types['fixed_pricing'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { + return self::getFixedPricing($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'fixed_pricing'); - $types['tank_cleaning'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { - return self::getTankCleaning($dateFrom, $dateTo, $customersWithTransactions); + $types['tank_cleaning'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { + return self::getTankCleaning($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'tank_cleaning'); - $types['special_arrangements'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { - return self::getSpecialArrangements($dateFrom, $dateTo, $customersWithTransactions); + $types['special_arrangements'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { + return self::getSpecialArrangements($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'special_arrangements'); - $types['invoice_per_order'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { - return self::getInvoicingPerOrder($dateFrom, $dateTo, $customersWithTransactions); + $types['invoice_per_order'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { + return self::getInvoicingPerOrder($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'invoice_per_order'); - $types['possible_duplicates'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions) { - return self::getPossibleDuplicates($dateFrom, $dateTo, $customersWithTransactions); + $types['possible_duplicates'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) { + return self::getPossibleDuplicates($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers); }, 'possible_duplicates'); $queueOverlay = self::debugGetTime(function () use ($dateFrom, $dateTo) { return self::getActiveCollectedInvoiceQueueOverlay($dateFrom, $dateTo); @@ -1139,6 +1880,22 @@ class InvoicingPeriodRoute $queueOverlay['by_collection_id'] ?? [], $queueOverlay['by_customer_number'] ?? [], ); + $draftOverlay = self::debugGetTime(function () use ($types, $dateFrom, $dateTo) { + return self::getValidCollectedInvoiceDraftOverlay($types, $dateFrom, $dateTo); + }, 'valid_collected_invoice_draft_overlay'); + $types = self::applyCollectedInvoiceDraftOverlayToPeriodTypes( + $types, + $draftOverlay['by_collection_id'] ?? [], + $draftOverlay['by_customer_number'] ?? [], + ); + $types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) { + return (new invoice_period_flag_service())->applyFlagsToPeriodTypes( + $types, + $dateFrom, + $dateTo, + $onlyCustomerNumbers + ); + }, 'invoice_period_flags'); return [ 'dateFrom' => $dateFrom, 'dateTo' => $dateTo, @@ -1173,89 +1930,43 @@ class InvoicingPeriodRoute */ private static function getCustomersWithTransactions(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array { - // Define the customers with orders in the specified date range - $customers = self::debugGetTime(function () use ($dateFrom, $dateTo) { - return (new orders_o())->getCustomersWithOrdersInDateRange($dateFrom, $dateTo); - }, 'customers_with_orders_in_date_range'); - //$customers = (new orders_o)->getCustomersWithOrdersInDateRange($dateFrom, $dateTo); + $onlyCustomerNumbers = $onlyCustomerNumbers !== null + ? self::normalizeCustomerNumbers($onlyCustomerNumbers) + : null; + if ($onlyCustomerNumbers !== null && empty($onlyCustomerNumbers)) { + return []; + } + + $customer_number_transactions = []; + self::debugGetTime(function () use ($onlyCustomerNumbers, $dateFrom, $dateTo, &$customer_number_transactions) { + $customer_number_transactions = (new orders_o())->getPeriodTransactionsForCustomersInDateRange( + $onlyCustomerNumbers, + $dateFrom, + $dateTo + ); + }, 'get_transactions_for_customers_in_date_range'); - /** - * // user_id => customer_number, - * @example - * [ - * '123' => '12345678', - * '456' => '87654321' - * ] - */ $customer_numbers = []; $tmp = []; - $allowed_customer_numbers = $onlyCustomerNumbers !== null - ? array_fill_keys(array_map('intval', $onlyCustomerNumbers), true) - : null; - self::debugGetTime(function () use ($customers, &$customer_numbers, $allowed_customer_numbers) { - // Process the customer numbers to ensure they are unique - foreach ( $customers as $customer ) { - $customer_number = (int)$customer->customer_number->value(); + self::debugGetTime(function () use ($customer_number_transactions, &$customer_numbers) { + foreach ( $customer_number_transactions as $customer_number => $transactions ) { + $customer_number = (int)$customer_number; if (empty($customer_number)) { - // Skip if the customer number is empty continue; } - if ($allowed_customer_numbers !== null && !isset($allowed_customer_numbers[$customer_number])) { - continue; - } - // Check if the customer number is already in the array - // This ensures that we only process each customer number once - // We use (int)$customer_number to ensure that the customer number is an integer if (isset($customer_numbers[$customer_number])) { continue; } - // Add the customer number to the array - $customer_numbers[$customer_number] = $customer->id; + $firstTransaction = is_array($transactions) ? ($transactions[0] ?? []) : []; + $userId = (int)($firstTransaction['user_id'] ?? 0); + $customer_numbers[$customer_number] = $userId > 0 ? $userId : null; } }, 'process_customer_numbers'); - // Get the transactions for the customers in the specified date range - /** - * @example - * [ - * '12345678' => [ - * orders_o, - * orders_o, - * ] - * ] - * @var $customer_number_transactions - */ - self::debugGetTime(function () use ($customer_numbers, $dateFrom, $dateTo, &$customer_number_transactions) { - if (empty($customer_numbers)) { - $customer_number_transactions = []; - return; - } - $customer_number_transactions = (new orders_o())->getTransactionsForCustomersInDateRange(array_keys($customer_numbers), $dateFrom, $dateTo); - }, 'get_transactions_for_customers_in_date_range'); - // Calculate the total amount for each transaction, to minimize the number of queries - self::debugGetTime(function () use ($customer_number_transactions) { - // Get all transaction ids - $transaction_ids = []; - foreach ( $customer_number_transactions as $customer_number => $transactions ) { - foreach ( $transactions as $transaction ) { - if ($transaction instanceof orders_o) { - $transaction_ids[] = $transaction->id; - } - } - } - // Get the total amount for each transaction - $transaction_totals = (new orders_o())->getNetAmountForOrders($transaction_ids); - // Add the total amount to each transaction - foreach ( $customer_number_transactions as $customer_number => $transactions ) { - foreach ( $transactions as $transaction ) { - if ($transaction instanceof orders_o) { - // Set the total amount for the transaction - $transaction->setTemporaryNetAmount($transaction_totals[$transaction->id] ?? 0); - } - } - } + self::debugGetTime(static function (): void { + // Net totals are resolved by getPeriodTransactionsForCustomersInDateRange(). }, 'calculate_transaction_totals'); // Get the customer names from the cache - $customer_names = (new \objects\users_o())->getCustomerNames(array_keys($customer_numbers)); + $customer_names = (new \objects\users_o())->getCustomerNames(array_keys($customer_numbers), false); // Process the customer numbers to ensure they are unique self::debugGetTime(function () use ($customer_numbers, $customer_number_transactions, &$tmp, $customer_names) { foreach ( $customer_numbers as $customer_number => $user_id ) { @@ -1273,10 +1984,9 @@ class InvoicingPeriodRoute $tmp[] = self::constructCustomerObject( (int)$customer_number, $customer_names[(int)$customer_number] ?? 'Unknown Customer', - //(new \objects\users_o())->getCustomerName((int)$customer_number), $customer_number_transactions[(int)$customer_number] ?? [], false, - (int)$user_id, + (int)$user_id > 0 ? (int)$user_id : null, ); } }, 'construct_customer_objects'); @@ -1300,9 +2010,12 @@ class InvoicingPeriodRoute ?array $meta = null ): array { - $user = (new users_o())->getUserByCustomerNumber((int)$customer_number); + $user = null; + if ($user_id === null) { + $user = (new users_o())->getUserByCustomerNumber((int)$customer_number); + } return [ - 'id' => $user_id ?? ($user->exists() ? $user->id : null), + 'id' => $user_id ?? ($user !== null && $user->exists() ? $user->id : null), 'customer_number' => $customer_number, 'customer_name' => $customer_name, 'transactions' => $parsed_transactions = array_map(function ($transaction) { @@ -1311,22 +2024,37 @@ class InvoicingPeriodRoute 'requires_action' => self::checkRequiresAction($parsed_transactions, $requires_action), 'meta' => $meta ?? [], 'queue' => self::getDefaultQueueSummary(), + 'draft' => self::getDefaultDraftSummary(), ]; } /** * @throws Exception */ - private static function constructTransactionObject(orders_o $transaction): array + private static function constructTransactionObject(mixed $transaction): array { + if (is_array($transaction)) { + return self::constructTransactionObjectFromPeriodRow($transaction); + } + if (!$transaction instanceof orders_o) { + throw new \InvalidArgumentException('Invalid period transaction row.'); + } + $departmentId = (int)$transaction->department_id->value(); $invoiceCollectionId = (int)$transaction->invoice_collection_id->value(); return [ 'id' => $transaction->id, 'date' => $transaction->created_at->value(), 'amount' => $transaction->temporary_net_amount, // Use the temporary net amount set earlier - 'booked' => $transaction->isBooked(true), + 'booked' => self::isTransactionBookedFromLocalState($transaction), 'department_id' => $departmentId, + 'customer_number' => (int)$transaction->customer_id->value(), + 'reference' => (string)$transaction->reference->value(), + 'po' => (string)$transaction->po->value(), + 'notes' => (string)$transaction->notes->value(), + 'reg_1' => (string)$transaction->reg_1->value(), + 'reg_2' => (string)$transaction->reg_2->value(), + 'reg_3' => (string)$transaction->reg_3->value(), 'excluded' => !$transaction->isIncludedInInvoicing(), 'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null, 'queue_status' => null, @@ -1334,6 +2062,61 @@ class InvoicingPeriodRoute ]; } + private static function constructTransactionObjectFromPeriodRow(array $transaction): array + { + $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); + return [ + 'id' => (int)($transaction['id'] ?? $transaction['order_id'] ?? 0), + 'date' => (string)($transaction['date'] ?? $transaction['created_at'] ?? ''), + 'amount' => (float)($transaction['amount'] ?? $transaction['net_amount'] ?? 0), + 'booked' => (bool)($transaction['booked'] ?? false), + 'department_id' => (int)($transaction['department_id'] ?? 0), + 'customer_number' => (int)($transaction['customer_number'] ?? $transaction['customer_id'] ?? 0), + 'reference' => (string)($transaction['reference'] ?? $transaction['order_reference'] ?? ''), + 'po' => (string)($transaction['po'] ?? $transaction['order_po'] ?? ''), + 'notes' => (string)($transaction['notes'] ?? $transaction['order_notes'] ?? ''), + 'reg_1' => (string)($transaction['reg_1'] ?? ''), + 'reg_2' => (string)($transaction['reg_2'] ?? ''), + 'reg_3' => (string)($transaction['reg_3'] ?? ''), + 'excluded' => (bool)($transaction['excluded'] ?? ((int)($transaction['include_in_invoice_effective'] ?? 1) !== 1)), + 'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null, + 'queue_status' => $transaction['queue_status'] ?? null, + 'queue_job_id' => $transaction['queue_job_id'] ?? null, + ]; + } + + /** + * Resolve booked state from local stored invoice metadata only. + * Remote e-conomic invoice lookups are intentionally avoided here because this method runs for every + * transaction in the period response. + */ + private static function isTransactionBookedFromLocalState(orders_o $transaction): bool + { + global $db; + + $orderId = (int)$transaction->id; + if ($orderId < 1) { + return false; + } + if (array_key_exists($orderId, self::$periodOrderBookedCache)) { + return self::$periodOrderBookedCache[$orderId]; + } + + $invoiceCollectionId = (int)$transaction->invoice_collection_id->value(); + if ($invoiceCollectionId > 0) { + if (!array_key_exists($invoiceCollectionId, self::$periodInvoiceCollectionBookedCache)) { + $result = $db->query("SELECT booked_invoice_id FROM collected_order_invoices WHERE id = {$invoiceCollectionId} LIMIT 1"); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : null; + self::$periodInvoiceCollectionBookedCache[$invoiceCollectionId] = !empty($row['booked_invoice_id'] ?? null); + } + return self::$periodOrderBookedCache[$orderId] = self::$periodInvoiceCollectionBookedCache[$invoiceCollectionId]; + } + + $result = $db->query("SELECT invoice_id FROM economic_module_orders WHERE id = {$orderId} LIMIT 1"); + $row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : null; + return self::$periodOrderBookedCache[$orderId] = !empty($row['invoice_id'] ?? null); + } + private static function checkRequiresAction(array $parsed_transactions, bool $requires_action): bool { // If requires_action is already set to true, return true @@ -1360,6 +2143,15 @@ class InvoicingPeriodRoute ]; } + private static function getDefaultDraftSummary(): array + { + return [ + 'has_valid_draft' => false, + 'invoice_collection_ids' => [], + 'is_action_blocked' => false, + ]; + } + private static function getActiveCollectedInvoiceQueueOverlay(string $dateFrom, string $dateTo): array { $overlay = [ @@ -1467,6 +2259,24 @@ class InvoicingPeriodRoute return $normalizedTimestamp >= $dateFrom && $normalizedTimestamp <= $dateTo; } + private static function collectedOrderInvoicesHasDeletedAtColumn(): bool + { + if (self::$collectedOrderInvoicesHasDeletedAtColumn !== null) { + return self::$collectedOrderInvoicesHasDeletedAtColumn; + } + + global $db; + + try { + $result = $db->query("SHOW COLUMNS FROM `collected_order_invoices` LIKE 'deleted_at'"); + self::$collectedOrderInvoicesHasDeletedAtColumn = $result !== false && (int)$result->num_rows > 0; + } catch (\Throwable) { + self::$collectedOrderInvoicesHasDeletedAtColumn = false; + } + + return self::$collectedOrderInvoicesHasDeletedAtColumn; + } + private static function applyCollectedInvoiceQueueOverlayToPeriodTypes( array $types, array $queueJobsByCollectionId, @@ -1582,6 +2392,235 @@ class InvoicingPeriodRoute return $customer; } + private static function getValidCollectedInvoiceDraftOverlay(array $types, string $dateFrom, string $dateTo): array + { + $overlay = [ + 'by_collection_id' => [], + 'by_customer_number' => [], + ]; + + $candidateInvoiceCollectionIds = []; + $customerLevelCandidateNumbers = []; + + foreach ($types as $customers) { + if (!is_array($customers)) { + continue; + } + + foreach ($customers as $customer) { + if (!is_array($customer)) { + continue; + } + + foreach (($customer['transactions'] ?? []) as $transaction) { + $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + $candidateInvoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; + } + } + + $customerNumber = (int)($customer['customer_number'] ?? 0); + if ($customerNumber > 0 && self::customerSupportsCustomerLevelQueueBlocking($customer)) { + $customerLevelCandidateNumbers[$customerNumber] = $customerNumber; + } + } + } + + if (empty($candidateInvoiceCollectionIds) && empty($customerLevelCandidateNumbers)) { + return $overlay; + } + + global $db; + + try { + $whereCandidates = []; + if (!empty($candidateInvoiceCollectionIds)) { + $whereCandidates[] = 'id IN (' . implode(',', array_map('intval', array_values($candidateInvoiceCollectionIds))) . ')'; + } + if (!empty($customerLevelCandidateNumbers)) { + $dateFromEscaped = $db->escape_string($dateFrom); + $dateToEscaped = $db->escape_string($dateTo); + $whereCandidates[] = '(customer_number IN (' . implode(',', array_map('intval', array_values($customerLevelCandidateNumbers))) . ') + AND ( + DATE(closed_at) BETWEEN \'' . $dateFromEscaped . '\' AND \'' . $dateToEscaped . '\' + OR DATE(created_at) BETWEEN \'' . $dateFromEscaped . '\' AND \'' . $dateToEscaped . '\' + ))'; + } + + if (!defined('\objects\ECONOMIC_PROCESSOR')) { + class_exists(collected_order_invoices_o::class); + } + $processor = defined('\objects\ECONOMIC_PROCESSOR') + ? (int)constant('\objects\ECONOMIC_PROCESSOR') + : 1; + $deletedAtFilter = self::collectedOrderInvoicesHasDeletedAtColumn() + ? 'deleted_at IS NULL + AND ' + : ''; + $sql = "SELECT id, customer_number, created_at, closed_at + FROM collected_order_invoices + WHERE {$deletedAtFilter}processor = $processor + AND external_id IS NOT NULL + AND external_id <> '' + AND booked_invoice_id IS NULL + AND error_message IS NULL + AND (" . implode(' OR ', $whereCandidates) . ")"; + + $result = $db->query($sql); + if (!$result) { + return $overlay; + } + + while ($row = $result->fetch_assoc()) { + $invoiceCollectionId = (int)($row['id'] ?? 0); + $customerNumber = (int)($row['customer_number'] ?? 0); + if ($invoiceCollectionId < 1 || $customerNumber < 1) { + continue; + } + + $normalizedDraft = [ + 'invoice_collection_id' => $invoiceCollectionId, + 'customer_number' => $customerNumber, + 'created_at' => (string)($row['created_at'] ?? ''), + 'closed_at' => (string)($row['closed_at'] ?? ''), + 'is_period_relevant' => self::isInvoiceCollectionRelevantToPeriod( + (string)($row['created_at'] ?? ''), + (string)($row['closed_at'] ?? ''), + $dateFrom, + $dateTo + ), + ]; + + $overlay['by_collection_id'][$invoiceCollectionId] = $normalizedDraft; + $overlay['by_customer_number'][$customerNumber] = $overlay['by_customer_number'][$customerNumber] ?? []; + $overlay['by_customer_number'][$customerNumber][] = $normalizedDraft; + } + } catch (\Throwable) { + return $overlay; + } + + return $overlay; + } + + private static function applyCollectedInvoiceDraftOverlayToPeriodTypes( + array $types, + array $draftsByCollectionId, + array $draftsByCustomerNumber + ): array { + foreach ($types as $type => $customers) { + if (!is_array($customers)) { + continue; + } + + $types[$type] = array_map(function ($customer) use ($draftsByCollectionId, $draftsByCustomerNumber) { + if (!is_array($customer)) { + return $customer; + } + + return self::applyCollectedInvoiceDraftOverlayToCustomer( + $customer, + $draftsByCollectionId, + $draftsByCustomerNumber + ); + }, $customers); + } + + return $types; + } + + private static function applyCollectedInvoiceDraftOverlayToCustomer( + array $customer, + array $draftsByCollectionId, + array $draftsByCustomerNumber + ): array { + $customerNumber = (int)($customer['customer_number'] ?? 0); + $activeCustomerDrafts = array_values(array_filter( + $draftsByCustomerNumber[$customerNumber] ?? [], + static function ($draft): bool { + return !empty($draft['is_period_relevant']); + } + )); + + $transactions = []; + $actionableTransactionCount = 0; + $coveredActionableTransactionCount = 0; + $queuedActionableTransactionCount = 0; + $draftActionableTransactionCount = 0; + $invoiceCollectionIds = []; + + foreach (($customer['transactions'] ?? []) as $transaction) { + if (!is_array($transaction)) { + continue; + } + + $transaction['invoice_collection_id'] = isset($transaction['invoice_collection_id']) && (int)$transaction['invoice_collection_id'] > 0 + ? (int)$transaction['invoice_collection_id'] + : null; + + $isActionable = !($transaction['booked'] ?? false) && !($transaction['excluded'] ?? false); + if (!$isActionable) { + $transactions[] = $transaction; + continue; + } + + $actionableTransactionCount++; + $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); + $isQueued = !empty($transaction['queue_status']); + $isDraft = $invoiceCollectionId > 0 && isset($draftsByCollectionId[$invoiceCollectionId]); + + if ($isQueued || $isDraft) { + $coveredActionableTransactionCount++; + } + if ($isQueued) { + $queuedActionableTransactionCount++; + } + if ($isDraft) { + $draftActionableTransactionCount++; + $invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; + } + + $transactions[] = $transaction; + } + + foreach ($activeCustomerDrafts as $draft) { + $invoiceCollectionId = (int)($draft['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + $invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; + } + } + + $isDraftActionBlocked = false; + $queue = is_array($customer['queue'] ?? null) ? $customer['queue'] : self::getDefaultQueueSummary(); + if ($actionableTransactionCount > 0 && $coveredActionableTransactionCount === $actionableTransactionCount) { + if ($queuedActionableTransactionCount > 0) { + $queue['is_action_blocked'] = true; + } elseif ($draftActionableTransactionCount > 0) { + $isDraftActionBlocked = true; + } + } elseif ( + $actionableTransactionCount === 0 + && self::customerSupportsCustomerLevelQueueBlocking($customer) + && !empty($activeCustomerDrafts) + && empty($queue['is_action_blocked']) + ) { + $isDraftActionBlocked = true; + } + + $customer['transactions'] = $transactions; + $customer['queue'] = $queue; + $customer['draft'] = [ + 'has_valid_draft' => !empty($invoiceCollectionIds), + 'invoice_collection_ids' => array_values($invoiceCollectionIds), + 'is_action_blocked' => $isDraftActionBlocked, + ]; + + if ($isDraftActionBlocked || !empty($queue['is_action_blocked'])) { + $customer['requires_action'] = false; + } + + return $customer; + } + private static function customerSupportsCustomerLevelQueueBlocking(array $customer): bool { $meta = $customer['meta'] ?? []; @@ -1594,19 +2633,24 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getVehicleSubscriptions(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array + private static function getVehicleSubscriptions(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // If customersWithTransactions is not provided, get all customers with transactions in the specified date range if ($customersWithTransactions === null) { - $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo); + $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } // Since these are monthly subscriptions, we don't need to filter by transactions - $customer_numbers = (new \objects\users_o())->getCustomersWithVehicleSubscriptions(); + $customer_numbers = self::filterCustomerNumbers( + (new \objects\users_o())->getCustomersWithVehicleSubscriptions(), + $onlyCustomerNumbers + ); // Get all customers with vehicle subscriptions $subscriptions = []; + $customer_names = (new \objects\users_o())->getCustomerNames(array_map('intval', $customer_numbers), false); + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { - $customer = self::getCustomerFromList((int)$customer_number, $customersWithTransactions); + $customer = $customers_by_number[(int)$customer_number] ?? null; if ($customer !== null) { $customer['meta'] = array_merge($customer['meta'] ?? [], [ 'has_vehicle_subscription' => true, @@ -1617,7 +2661,7 @@ class InvoicingPeriodRoute $subscriptions[] = self::constructCustomerObject( (int)$customer_number, - (new \objects\users_o())->getCustomerName((int)$customer_number) ?? 'Unknown Customer', + $customer_names[(int)$customer_number] ?? 'Unknown Customer', [], true, null, @@ -1638,6 +2682,11 @@ class InvoicingPeriodRoute */ private static function getCustomerFromList(int $customer_number, array $customersWithTransactions): ?array { + $direct = $customersWithTransactions[$customer_number] ?? null; + if (is_array($direct) && (int)($direct['customer_number'] ?? 0) === $customer_number) { + return $direct; + } + // Search for the customer in the list of customers with transactions foreach ( $customersWithTransactions as $customer ) { if ($customer['customer_number'] === $customer_number) { @@ -1651,20 +2700,20 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getFixedPricing(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array + private static function getFixedPricing(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // Get all customers with fixed pricing - $customer_numbers = (new \objects\users_o())->getCustomersWithFixedPricing(); + $customer_numbers = self::filterCustomerNumbers( + (new \objects\users_o())->getCustomersWithFixedPricing(), + $onlyCustomerNumbers + ); // If customersWithTransactions is not provided, only resolve transaction customers for fixed-pricing customers. if ($customersWithTransactions === null) { $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $customer_numbers); } - $customers_by_number = []; - foreach ( $customersWithTransactions as $customer ) { - $customers_by_number[(int)$customer['customer_number']] = $customer; - } + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); // Get the customers fixed pricing $tmp_fixed_pricing = array_map(function ($arr) { @@ -1681,7 +2730,7 @@ class InvoicingPeriodRoute $fixed_pricing_by_customer_number[(int)$item['customer_number']] = $item; } - $customer_names = (new \objects\users_o())->getCustomerNames(array_map('intval', $customer_numbers)); + $customer_names = (new \objects\users_o())->getCustomerNames(array_map('intval', $customer_numbers), false); // Get all customers with fixed pricing $fixed_pricing = []; @@ -1727,23 +2776,27 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getTankCleaning(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array + private static function getTankCleaning(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // If customersWithTransactions is not provided, get all customers with transactions in the specified date range if ($customersWithTransactions === null) { - $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo); + $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } // Filter out customers that do not have any transactions in the specified date range - $customer_numbers = (new \objects\users_o())->getCustomersWithTankCleaning(); + $customer_numbers = self::filterCustomerNumbers( + (new \objects\users_o())->getCustomersWithTankCleaning(), + $onlyCustomerNumbers + ); self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions); // Get all customers with tank cleaning $tank_cleaning = []; + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { - $tank_cleaning[] = self::getCustomerFromList( - (int)$customer_number, - $customersWithTransactions - ); + $customer = $customers_by_number[(int)$customer_number] ?? null; + if ($customer !== null) { + $tank_cleaning[] = $customer; + } // Add the tank cleaning to the list if it has transactions } return $tank_cleaning; @@ -1757,38 +2810,36 @@ class InvoicingPeriodRoute */ private static function filterCustomersWithTransactions(array &$customer_numbers, array $customersWithTransactions): void { - // Filter out customers that do not have any transactions in the specified date range - $customer_numbers = array_filter($customer_numbers, function ($customer_number) use ($customersWithTransactions) { - // Check if the customer has any transactions in the specified date range - foreach ( $customersWithTransactions as $customer ) { - if ($customer['customer_number'] === $customer_number) { - return true; - } - } - return false; - }); + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); + $customer_numbers = array_values(array_filter($customer_numbers, static function ($customer_number) use ($customers_by_number): bool { + return isset($customers_by_number[(int)$customer_number]); + })); } /** * @throws Exception */ - private static function getSpecialArrangements(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array + private static function getSpecialArrangements(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // If customersWithTransactions is not provided, get all customers with transactions in the specified date range if ($customersWithTransactions === null) { - $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo); + $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } // Get all customers with tank cleaning - $customer_numbers = (new \objects\users_o())->getCustomersWithSpecialArrangements(); + $customer_numbers = self::filterCustomerNumbers( + (new \objects\users_o())->getCustomersWithSpecialArrangements(), + $onlyCustomerNumbers + ); // Filter out customers that do not have any transactions in the specified date range self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions); $special_arrangements = []; + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { - $special_arrangements[] = self::getCustomerFromList( - (int)$customer_number, - $customersWithTransactions - ); + $customer = $customers_by_number[(int)$customer_number] ?? null; + if ($customer !== null) { + $special_arrangements[] = $customer; + } } return $special_arrangements; } @@ -1796,21 +2847,28 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getInvoicingPerOrder(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array + private static function getInvoicingPerOrder(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // Get all customers with the invoicing per order attribute - $customer_numbers = (new users_o())->getCustomerNumbersWithAttributes(['invoiceAllOrdersIndividually']); + $customer_numbers = self::filterCustomerNumbers( + (new users_o())->getCustomerNumbersWithAttributes(['invoiceAllOrdersIndividually']), + $onlyCustomerNumbers + ); // Filter out customers that do not have any transactions in the specified date range if ($customersWithTransactions === null) { - $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo); + $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions); $invoicing_per_order = []; + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $customer_numbers as $customer_number ) { // Add the invoicing per order to the list - $invoicing_per_order[] = self::getCustomerFromList((int)$customer_number, $customersWithTransactions); + $customer = $customers_by_number[(int)$customer_number] ?? null; + if ($customer !== null) { + $invoicing_per_order[] = $customer; + } } return $invoicing_per_order; } @@ -1818,22 +2876,52 @@ class InvoicingPeriodRoute /** * @throws Exception */ - private static function getPossibleDuplicates(string $dateFrom, string $dateTo, array $customersWithTransactions = null): array + private static function getPossibleDuplicates(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array { // If customersWithTransactions is not provided, get all customers with transactions in the specified date range if ($customersWithTransactions === null) { - $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo); + $customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers); } - // Get orders with the same reg_1, that has been created within 24 hours of each other - $orders = (new orders_o())->getOrdersWithPossibleDuplicates($dateFrom, $dateTo); - // Get the customer numbers from the orders + $allowedCustomerNumbers = $onlyCustomerNumbers !== null + ? array_fill_keys(self::normalizeCustomerNumbers($onlyCustomerNumbers), true) + : null; + $ordersByRegistration = []; + foreach ($customersWithTransactions as $customer) { + foreach (($customer['transactions'] ?? []) as $transaction) { + $transaction = self::constructTransactionObject($transaction); + $registration = trim((string)($transaction['reg_1'] ?? '')); + if ($registration === '') { + continue; + } + $customerNumber = (int)($transaction['customer_number'] ?? 0); + if ($allowedCustomerNumbers !== null && !isset($allowedCustomerNumbers[$customerNumber])) { + continue; + } + $ordersByRegistration[$registration][] = [ + 'id' => (int)$transaction['id'], + 'created_at' => (string)$transaction['date'], + 'customer_number' => $customerNumber, + 'object' => $transaction, + ]; + } + } + $orders = invoicing_period_utils::filterPossibleDuplicates($ordersByRegistration, 86400); $tmp_customer_arr = []; // Remove duplicates from the customer numbers $possible_duplicates = []; + $customer_names = []; + foreach ($orders as $order) { + $customer_names[(int)($order[0]['customer_number'] ?? 0)] = true; + } + $customer_names = (new \objects\users_o())->getCustomerNames(array_keys($customer_names), false); + $customers_by_number = self::indexCustomersByNumber($customersWithTransactions); /** @var int $customer_number */ foreach ( $orders as $order ) { // Get the customer number from the order - $customer_number = (int)$order[0]['object']->customer_id->value(); + $customer_number = (int)($order[0]['customer_number'] ?? 0); + if ($allowedCustomerNumbers !== null && !isset($allowedCustomerNumbers[$customer_number])) { + continue; + } // Check if the customer number is already in the array if (isset($tmp_customer_arr[$customer_number])) { continue; @@ -1841,11 +2929,11 @@ class InvoicingPeriodRoute // Add the customer number to the array $tmp_customer_arr[$customer_number] = true; // Get the customer from the list of customers with transactions - $customer = self::getCustomerFromList($customer_number, $customersWithTransactions); + $customer = $customers_by_number[$customer_number] ?? null; // Add the customer to the possible duplicates array $possible_duplicates[] = self::constructCustomerObject( $customer_number, - (new \objects\users_o())->getCustomerName($customer_number) ?? 'Unknown Customer', + $customer_names[$customer_number] ?? 'Unknown Customer', array_map(function ($transaction) { // Construct the transaction object from the order return $transaction['object']; diff --git a/services/nginx/app/routes/authRoute.php b/services/nginx/app/routes/authRoute.php index ec534bd0..5149ebff 100644 --- a/services/nginx/app/routes/authRoute.php +++ b/services/nginx/app/routes/authRoute.php @@ -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)) { @@ -825,7 +829,9 @@ class authRoute [ 'economic' => [ 'transaction_draft_customer_number' => (new economic())->getTransactionDraftCustomerNumber(), + 'default_distribution_department_id' => (new economic())->getDefaultDistributionDepartmentId(), ], + 'release' => (new release_manager())->runtimeForPayload($payload, $this->getParametersAsArray()), ] ); diff --git a/services/nginx/app/routes/bookingsRoute.php b/services/nginx/app/routes/bookingsRoute.php index d036b0d3..01c79042 100644 --- a/services/nginx/app/routes/bookingsRoute.php +++ b/services/nginx/app/routes/bookingsRoute.php @@ -468,6 +468,7 @@ class bookingsRoute // Require the user to be logged in global /** @var response $response */ $response; + $response->error('Booking completion must be completed through POS desktop or mobile steps.', 410); $this->requirePermission('complete_wash_without_wash_certificate'); // Get the user object $user = (new authentication())->get_user(); diff --git a/services/nginx/app/routes/departmentLanesRoute.php b/services/nginx/app/routes/departmentLanesRoute.php index 96497cc2..3a6bb12f 100644 --- a/services/nginx/app/routes/departmentLanesRoute.php +++ b/services/nginx/app/routes/departmentLanesRoute.php @@ -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 @@ -60,6 +89,7 @@ class departmentLanesRoute 'relay_machine_cleaner_id', 'dynamic_image_id', 'machine_type_id', + 'selfserve_enabled', ]) ->listObjectsWithPaginationIfSet( function ($department_lane) use ($user) { @@ -134,7 +164,7 @@ class departmentLanesRoute * Query parameters: * - department (int, required) * - lane (int, required) - * - buttons (array|json|csv, optional) → highlighted button IDs (0-indexed) + * - 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 @@ -169,6 +199,15 @@ class departmentLanesRoute // Resolve dynamic image id → class (support id=1 for now) $dynamic_image_id = $lane->dynamic_image_id->value(); + if ($response->isRequestParameterSet('dynamic_image_id')) { + $dynamic_image_override = $response->getRequestParameter('dynamic_image_id'); + if ($dynamic_image_override === null || $dynamic_image_override === '' || strtolower((string)$dynamic_image_override) === 'null') { + $dynamic_image_id = null; + } else { + $dynamic_image_id = (int)$dynamic_image_override; + self::requireMinValue($dynamic_image_id, 1); + } + } if ($dynamic_image_id === null) { $response->error('No dynamic image configured for this lane', 404); } @@ -242,11 +281,9 @@ class departmentLanesRoute switch ($dynamic_image_id) { case 1: $image = new machine_1(); - // Require thumb_position for machine_1 - if ($thumb_position === null) { - $response->error('thumb_position parameter is required for this dynamic image', 400); + if ($thumb_position !== null) { + $image->thumb_position = $thumb_position; } - $image->thumb_position = $thumb_position; break; default: $response->error('Unsupported dynamic image id: ' . $dynamic_image_id, 400); @@ -310,6 +347,9 @@ class departmentLanesRoute $dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null; $machine_type_id = $response->getRequestParameter('machine_type_id') ?? null; + $selfserve_enabled = self::isParametersSet(['selfserve_enabled']) + ? department_lanes_o::normalizeSelfServeEnabledValue($response->getRequestParameter('selfserve_enabled')) + : true; if ($dynamic_image_id !== null) { $did = (int)$dynamic_image_id; $this->requireType($did, $this->type_int()); @@ -326,9 +366,12 @@ class departmentLanesRoute // Check if the required fields are set if ($name && $department) { // Add the department lane - (new department_lanes_o())->add((int)$department, (string)$name, $relay_in_id, $relay_out_id, $relay_machine_id, $relay_machine_program_picker_id, $relay_machine_cleaner_id, $dynamic_image_id, $machine_type_id); + $created_lane = (new department_lanes_o())->add((int)$department, (string)$name, $relay_in_id, $relay_out_id, $relay_machine_id, $relay_machine_program_picker_id, $relay_machine_cleaner_id, $dynamic_image_id, $machine_type_id, $selfserve_enabled); // Return a success message - $response->success('Department lane added'); + $response->success([ + 'message' => 'Department lane added', + 'lane' => $created_lane->asArray(), + ]); } else { // Return an error $response->error('Missing required fields', 400); @@ -378,6 +421,7 @@ class departmentLanesRoute // Return an error $response->error('Department lane not found', 404); } + $was_selfserve_enabled = $department_lane->isSelfServeEnabled(); // Update the department lane fields that are set if (self::isParametersSet(['name'])) { $department_lane->name->set($name); @@ -422,8 +466,18 @@ class departmentLanesRoute $department_lane->machine_type_id->set($machineTypeId); } } + if (self::isParametersSet(['selfserve_enabled'])) { + $next_selfserve_enabled = department_lanes_o::normalizeSelfServeEnabledValue($response->getRequestParameter('selfserve_enabled')); + $department_lane->selfserve_enabled->set($next_selfserve_enabled); + if ($was_selfserve_enabled && !$next_selfserve_enabled) { + department_lanes_o::disableSelfServeRelaysBestEffort((int)$department_lane->id); + } + } // Return a success message - $response->success('Department lane updated'); + $response->success([ + 'message' => 'Department lane updated', + 'lane' => $department_lane->asArray(), + ]); } else { // Log the incident (new logs_o())->add('department_lanes', 'global', 1, 0, 'EDIT_DEPARTMENT_LANE', 'User tried to edit a department lane without being logged in'); diff --git a/services/nginx/app/routes/departmentSelfserveStudioRoute.php b/services/nginx/app/routes/departmentSelfserveStudioRoute.php new file mode 100644 index 00000000..adf3e87c --- /dev/null +++ b/services/nginx/app/routes/departmentSelfserveStudioRoute.php @@ -0,0 +1,340 @@ +get('/department/selfserve/studio/graph', function (): void { + global $response; + $user = $this->requireStudioUser('list_department_selfserve_config_versions'); + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId, ['view_all_department_selfserve_config_versions']); + + $service = new selfserve_studio_graph(); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'GET_STUDIO_GRAPH', 'Fetched self-serve studio graph'); + $response->success($service->buildGraph($departmentId, (int)$user->id, $this->studioPermissions())); + }, [ + 'list_department_selfserve_config_versions' => 'View the all-in-one self-serve studio graph', + 'view_all_department_selfserve_config_versions' => 'View the all-in-one self-serve studio graph across departments', + ]); + + $this->put('/department/selfserve/studio/graph', function (): void { + global $response; + $user = $this->requireStudioUser('edit_department_selfserve_config_versions'); + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $payload = self::getParametersAsArray(); + $service = new selfserve_studio_graph(); + $graph = $service->applyGraphSave($departmentId, $payload, (int)$user->id, $this->studioPermissions()); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'SAVE_STUDIO_GRAPH', 'Saved self-serve studio graph'); + $response->success($graph); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'edit_department_selfserve_config_versions' => 'Create, update, delete, connect, and reorder self-serve studio graph objects', + ]); + + $this->put('/department/selfserve/studio/layout', function (): void { + global $response; + $user = $this->requireStudioUser('edit_department_selfserve_config_versions'); + self::requireParameters(['department', 'layout']); + self::requireType(self::getParameter('layout'), self::TYPE_ARRAY()); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $layout = (new selfserve_studio_graph())->saveLayout($departmentId, (int)$user->id, (array)self::getParameter('layout')); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'SAVE_STUDIO_LAYOUT', 'Saved self-serve studio layout'); + $response->success($layout); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'edit_department_selfserve_config_versions' => 'Save canvas-only self-serve studio layout', + ]); + + $this->put('/department/selfserve/studio/virtual-hardware', function (): void { + global $response; + $user = $this->requireStudioUser('edit_department_selfserve_config_versions'); + self::requireParameters(['department', 'operation']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $payload = self::getParametersAsArray(); + $graph = (new selfserve_studio_graph())->applyVirtualHardwareOperation( + $departmentId, + $payload, + (int)$user->id, + $this->studioPermissions() + ); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'SAVE_STUDIO_VIRTUAL_HARDWARE', 'Saved self-serve studio virtual hardware'); + $response->success($graph); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'edit_department_selfserve_config_versions' => 'Generate and edit studio-only virtual hardware', + ]); + + $this->post('/department/selfserve/studio/validate', function (): void { + global $response; + $user = $this->requireStudioUser('edit_department_selfserve_config_versions'); + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + $validation = (new selfserve_studio_graph())->validatePayload($departmentId, self::getParametersAsArray()); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'VALIDATE_STUDIO_GRAPH', 'Validated self-serve studio graph'); + $response->success($validation); + }, [ + 'edit_department_selfserve_config_versions' => 'Validate the self-serve studio graph', + ]); + + $this->post('/department/selfserve/studio/simulate', function (): void { + global $response; + $user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions'); + self::requireParameters(['department', 'lane_id', 'reg']); + $departmentId = (int)self::getParameter('department'); + $laneId = (int)self::getParameter('lane_id'); + self::requireParameterIntPositive($laneId, 'lane_id'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $result = (new selfserve_studio_graph())->simulateGraph( + $departmentId, + self::getParametersAsArray(), + (int)$user->id, + $this->studioPermissions() + ); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'SIMULATE_STUDIO_GRAPH', 'Simulated self-serve studio graph'); + $response->success($result); + } catch (\Throwable $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'list_department_selfserve_vehicle_conditions' => 'Run the self-serve studio simulator', + ]); + + $this->post('/department/selfserve/studio/path-outcomes/stream', function (): void { + $user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions'); + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + ini_set('display_errors', '0'); + ini_set('html_errors', '0'); + header('Content-Type: application/x-ndjson; charset=utf-8'); + header('Cache-Control: no-cache, no-transform'); + header('X-Accel-Buffering: no'); + + $emit = static function (array $event): void { + echo json_encode($event, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + if (function_exists('ob_flush')) { + @ob_flush(); + } + @flush(); + }; + + try { + $result = (new selfserve_studio_graph())->projectPathOutcomes( + $departmentId, + self::getParametersAsArray(), + (int)$user->id, + $this->studioPermissions(), + static function (array $partial) use ($emit): void { + $emit(['type' => 'progress', 'data' => $partial]); + } + ); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'PROJECT_STUDIO_PATH_OUTCOMES', 'Projected self-serve studio path outcomes'); + $emit(['type' => 'complete', 'data' => $result]); + } catch (\Throwable $exception) { + $emit(['type' => 'error', 'message' => $exception->getMessage()]); + } + exit; + }, [ + 'list_department_selfserve_vehicle_conditions' => 'Stream grouped self-serve studio question path outcome progress', + ]); + + $this->post('/department/selfserve/studio/path-outcomes', function (): void { + global $response; + $user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions'); + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $result = (new selfserve_studio_graph())->projectPathOutcomes( + $departmentId, + self::getParametersAsArray(), + (int)$user->id, + $this->studioPermissions() + ); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'PROJECT_STUDIO_PATH_OUTCOMES', 'Projected self-serve studio path outcomes'); + $response->success($result); + } catch (\Throwable $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + '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'); + self::requireParameters(['department']); + $departmentId = (int)self::getParameter('department'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $published = (new selfserve_config_versioning())->publishDraft($departmentId, (int)$user->id); + $validation = (new selfserve_studio_graph())->validatePayload($departmentId); + $published['warnings'] = (array)($validation['warnings'] ?? []); + $published['validation'] = $validation; + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'PUBLISH_STUDIO_GRAPH', 'Published self-serve studio graph'); + $response->success($published); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'publish_department_selfserve_config_versions' => 'Publish the self-serve studio draft', + ]); + + $this->post('/department/selfserve/studio/rollback', function (): void { + global $response; + $user = $this->requireStudioUser('rollback_department_selfserve_config_versions'); + self::requireParameters(['department', 'target_version_id']); + $departmentId = (int)self::getParameter('department'); + $targetVersionId = (int)self::getParameter('target_version_id'); + self::requireParameterIntPositive($targetVersionId, 'target_version_id'); + $this->assertDepartmentAccess($user, $departmentId); + + try { + $rolledBack = (new selfserve_config_versioning())->rollbackToVersion($departmentId, $targetVersionId, (int)$user->id); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'ROLLBACK_STUDIO_GRAPH', 'Rolled back self-serve studio graph'); + $response->success($rolledBack); + } catch (\RuntimeException $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'rollback_department_selfserve_config_versions' => 'Rollback the self-serve studio draft to an earlier version', + ]); + + $this->post('/department/selfserve/studio/gateway-action', function (): void { + global $response; + $user = $this->requireStudioUser('modules_shelly_config'); + self::requireParameters(['department', 'gateway_id', 'action']); + $departmentId = (int)self::getParameter('department'); + $gatewayId = (int)self::getParameter('gateway_id'); + self::requireParameterIntPositive($gatewayId, 'gateway_id'); + $this->assertDepartmentAccess($user, $departmentId); + + $action = strtolower((string)self::getParameter('action')); + $confirmed = filter_var(self::getParameter('confirm'), FILTER_VALIDATE_BOOLEAN); + if (in_array($action, ['uninstall', 'rotate_credentials'], true) && $confirmed !== true) { + $response->error('This gateway action requires explicit confirmation.', 428); + } + + try { + $payload = self::getParametersAsArray(); + $result = (new selfserve_studio_graph())->runGatewayAction($departmentId, $gatewayId, $action, $payload, (int)$user->id); + (new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'GATEWAY_STUDIO_ACTION', 'Ran self-serve studio gateway action: ' . $action); + $response->success($result); + } catch (\Throwable $exception) { + $response->error($exception->getMessage(), 422); + } + }, [ + 'modules_shelly_config' => 'Run permission-gated self-serve studio edge gateway actions', + ]); + } + + private function requireStudioUser(string $permission): object + { + global $response; + $this->requirePermission($permission); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + return $user; + } + + /** + * @param array $bypassPermissions + */ + private function assertDepartmentAccess(object $user, int $departmentId, array $bypassPermissions = []): void + { + $authorizedDepartmentIds = $user->getGroup()->getDepartments(); + if (in_array($departmentId, $authorizedDepartmentIds, true)) { + return; + } + + foreach ($bypassPermissions as $permission) { + if ($this->hasPermission($permission)) { + return; + } + } + + $this->forbidDepartmentAccess($departmentId, $bypassPermissions); + } + + /** + * @return array + */ + private function studioPermissions(): array + { + return [ + 'can_view' => $this->hasPermission('list_department_selfserve_config_versions'), + 'can_edit' => $this->hasPermission('edit_department_selfserve_config_versions'), + 'can_publish' => $this->hasPermission('publish_department_selfserve_config_versions'), + 'can_rollback' => $this->hasPermission('rollback_department_selfserve_config_versions'), + 'can_simulate' => $this->hasPermission('list_department_selfserve_vehicle_conditions'), + 'can_add_department_lane' => $this->hasPermission('add_department_lane'), + 'can_edit_department_lane' => $this->hasPermission('edit_department_lane'), + 'modules_shelly_config' => $this->hasPermission('modules_shelly_config'), + 'can_manage_gateways' => $this->hasPermission('modules_shelly_config'), + 'can_run_gateway_destructive_actions' => $this->hasPermission('modules_shelly_config'), + 'can_run_live_lane_actions' => $this->hasPermission('modules_selfserve_sessions_force_stop'), + ]; + } +} diff --git a/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php b/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php index 4aed931c..d6efafe6 100644 --- a/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php +++ b/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php @@ -134,21 +134,20 @@ class departmentSelfserveVehicleConditionsRoute $lane = $this->assertLaneAccess($user, $lane_id, $has_global); $customer_number = null; if (!$has_global && $has_own) { - $vehicle = $this->assertOwnVehicle($user, $reg, 'list_department_selfserve_vehicle_conditions'); - $customer_number = (int)$vehicle->customer_id->value(); + $customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions'); } $vehicle_type_id = $this->resolveVehicleTypeIdFromQuery(); $flow = $this->getWashFlow(); if ($vehicle_type_id !== null) { - $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id); + $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false); } (new logs_o())->add('department_selfserve_vehicle_conditions', (int)$lane->department->value(), 1, $user->id, 'CHECK_VEHICLE_ALLOWED', 'User checked self-serve eligibility for lane ' . $lane_id . ' and vehicle ' . $reg); $response->success($flow->previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)); }, [ 'list_department_selfserve_vehicle_conditions' => 'Check whether self-serve is allowed for a specific vehicle', - 'list_own_department_selfserve_vehicle_conditions' => 'Check whether self-serve is allowed for an owned vehicle' + 'list_own_department_selfserve_vehicle_conditions' => 'Check whether self-serve is allowed for a customer-scoped vehicle' ]); /** @@ -182,7 +181,8 @@ class departmentSelfserveVehicleConditionsRoute ? (int)$summary['session']['customer_number'] : null, false, - $vehicle_type_id + $vehicle_type_id, + false ); } @@ -196,12 +196,11 @@ class departmentSelfserveVehicleConditionsRoute $this->assertLaneAccess($user, $lane_id, $has_global); $customer_number = null; if (!$has_global && $has_own) { - $vehicle = $this->assertOwnVehicle($user, $reg, 'list_department_selfserve_vehicle_conditions'); - $customer_number = (int)$vehicle->customer_id->value(); + $customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions'); } if ($vehicle_type_id !== null) { - $summary = $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id); + $summary = $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false); } else { $summary = $flow->getLatestSessionSummary($lane_id, $reg); } @@ -212,7 +211,7 @@ class departmentSelfserveVehicleConditionsRoute } }, [ 'list_department_selfserve_vehicle_conditions' => 'View self-serve wash summaries', - 'list_own_department_selfserve_vehicle_conditions' => 'View self-serve wash summaries for owned vehicles' + 'list_own_department_selfserve_vehicle_conditions' => 'View self-serve wash summaries for customer-scoped vehicles' ]); /** @@ -242,6 +241,8 @@ class departmentSelfserveVehicleConditionsRoute $response->error('Missing required fields', 400); } $vehicle_type_id = $this->resolveVehicleTypeIdFromRequest(); + $activate_machine = $this->requestBooleanFlag('activate_machine', true); + $sync_relay_state = $this->requestBooleanFlag('sync_relay_state', true); if ($has_global) { $customer_id = $response->isRequestParameterSet('customer_id') ? (int)$response->getRequestParameter('customer_id') : null; @@ -250,15 +251,13 @@ class departmentSelfserveVehicleConditionsRoute $this->forbidDepartmentAccess($department); } } else { - $customer_id = (int)$user->customer_number->value(); - $vehicle_o = $this->assertOwnVehicle($user, $reg, 'add_department_selfserve_vehicle_conditions'); - $customer_id = (int)$vehicle_o->customer_id->value(); + $customer_id = $this->requireAuthenticatedCustomerNumber($user, 'add_department_selfserve_vehicle_conditions'); } try { $condition_o = new department_selfserve_vehicle_conditions_o(); $condition_o->add($department, $lane, $reg, $question, $value, $customer_id); - $summary = $this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, true, $vehicle_type_id); + $summary = $this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state); (new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'ADD_VEHICLE_CONDITION', 'User added department self-serve vehicle condition ' . $condition_o->id); $response->success([ 'condition' => $condition_o->asArray(), @@ -269,7 +268,7 @@ class departmentSelfserveVehicleConditionsRoute } }, [ 'add_department_selfserve_vehicle_conditions' => 'Add a department self-serve vehicle condition', - 'add_own_department_selfserve_vehicle_conditions' => 'Add own department self-serve vehicle condition' + 'add_own_department_selfserve_vehicle_conditions' => 'Add customer-scoped department self-serve vehicle condition' ]); /** @@ -328,12 +327,6 @@ class departmentSelfserveVehicleConditionsRoute } if ($response->isRequestParameterSet('reg')) { $new_reg = selfserve::standardize_registration((string)$response->getRequestParameter('reg')); - if (!$has_global && $has_own) { - $vehicle_o = $this->assertOwnVehicle($user, $new_reg, 'update_department_selfserve_vehicle_conditions'); - if ((int)$vehicle_o->customer_id->value() !== $customer_number) { - $response->forbidden(['update_department_selfserve_vehicle_conditions']); - } - } $condition_o->reg->update($new_reg); } if ($response->isRequestParameterSet('question')) { @@ -350,14 +343,17 @@ class departmentSelfserveVehicleConditionsRoute $condition_o->customer_id->update($new_customer_id); } $vehicle_type_id = $this->resolveVehicleTypeIdFromRequest(); + $activate_machine = $this->requestBooleanFlag('activate_machine', true); + $sync_relay_state = $this->requestBooleanFlag('sync_relay_state', true); try { $summary = $this->getWashFlow()->synchronizeSession( (int)$condition_o->lane->value(), (string)$condition_o->reg->value(), $condition_o->customer_id->value() === null ? null : (int)$condition_o->customer_id->value(), - true, - $vehicle_type_id + $activate_machine, + $vehicle_type_id, + $sync_relay_state ); (new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'UPDATE_VEHICLE_CONDITION', 'User updated department self-serve vehicle condition ' . $id); $response->success([ @@ -369,7 +365,7 @@ class departmentSelfserveVehicleConditionsRoute } }, [ 'update_department_selfserve_vehicle_conditions' => 'Update a department self-serve vehicle condition', - 'update_own_department_selfserve_vehicle_conditions' => 'Update own department self-serve vehicle condition' + 'update_own_department_selfserve_vehicle_conditions' => 'Update customer-scoped department self-serve vehicle condition' ]); /** @@ -415,11 +411,13 @@ class departmentSelfserveVehicleConditionsRoute $reg = (string)$condition_o->reg->value(); $customer_id = $condition_o->customer_id->value() === null ? null : (int)$condition_o->customer_id->value(); $vehicle_type_id = $this->resolveVehicleTypeIdFromRequest(); + $activate_machine = $this->requestBooleanFlag('activate_machine', true); + $sync_relay_state = $this->requestBooleanFlag('sync_relay_state', true); $condition_o->delete(); try { - $summary = $this->getWashFlow()->synchronizeSession($lane_id, $reg, $customer_id, true, $vehicle_type_id); + $summary = $this->getWashFlow()->synchronizeSession($lane_id, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state); } catch (\Throwable) { $summary = null; } @@ -504,6 +502,31 @@ class departmentSelfserveVehicleConditionsRoute return $questions === [] && $tasks === []; } + private function requestBooleanFlag(string $parameter, bool $default): bool + { + if (!$this->isParametersSet([$parameter])) { + return $default; + } + + $value = $this->getParameter($parameter); + if (is_bool($value)) { + return $value; + } + if (is_int($value)) { + return $value !== 0; + } + + $normalized = strtolower(trim((string)$value)); + if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { + return false; + } + + return $default; + } + private function assertLaneAccess(object $user, int $laneId, bool $hasGlobalPermission): department_lanes_o { global $response; @@ -523,16 +546,16 @@ class departmentSelfserveVehicleConditionsRoute return $lane; } - private function assertOwnVehicle(object $user, string $reg, ?string $elevatedPermission = null): customer_vehicles_o + private function requireAuthenticatedCustomerNumber(object $user, string $elevatedPermission): int { global $response; - $vehicle_o = (new customer_vehicles_o())->selectByPlate($reg); - if (!$vehicle_o->exists() || (int)$vehicle_o->customer_id->value() !== (int)$user->customer_number->value()) { - $response->forbidden([$elevatedPermission ?? 'list_department_selfserve_vehicle_conditions']); + $customer_number = (int)$user->customer_number->value(); + if ($customer_number <= 0) { + $response->forbidden([$elevatedPermission]); } - return $vehicle_o; + return $customer_number; } private function assertSummaryAccess(object $user, array $summary, bool $hasGlobalPermission, string $elevatedPermission): void diff --git a/services/nginx/app/routes/departmentsRoute.php b/services/nginx/app/routes/departmentsRoute.php index 250a415a..43e965a3 100644 --- a/services/nginx/app/routes/departmentsRoute.php +++ b/services/nginx/app/routes/departmentsRoute.php @@ -17,6 +17,59 @@ class departmentsRoute { use route_t; + private function buildDepartmentListFilters(departments_o $departments, bool $canListArchived): string + { + global $response; + + $filters = $response->getRequestParameter('filters') ?? []; + if (is_string($filters) || is_array($filters)) { + $filters = $departments->filter_string_to_array($filters); + } else { + $filters = []; + } + + $archived = 0; + if ( + $canListArchived + && array_key_exists('archived', $filters) + && self::isTruthyBooleanValue($filters['archived']) + ) { + $archived = 1; + } + + unset($filters['visible'], $filters['archived']); + $filters['visible'] = 1; + $filters['archived'] = $archived; + + return $departments->array_to_filters($filters); + } + + private static function isTruthyBooleanValue(mixed $value): bool + { + if (is_array($value)) { + foreach ($value as $singleValue) { + if (self::isTruthyBooleanValue($singleValue)) { + return true; + } + } + return false; + } + + if (is_bool($value)) { + return $value; + } + + if (is_numeric($value)) { + return (int)$value === 1; + } + + if (is_string($value)) { + return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true); + } + + return false; + } + public function run(): void { $this->get('/departments', function () { @@ -49,6 +102,7 @@ class departmentsRoute 'description', 'economic_department_id', 'visible', + 'archived', 'longitude', 'latitude', ]) @@ -61,8 +115,10 @@ 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), 'longitude' => (float)$department['longitude'], 'latitude' => (float)$department['latitude'], 'order_priority' => (int)$department['order_priority'], @@ -73,9 +129,10 @@ class departmentsRoute } return $tmp_department; }, - $departments_o->forceRestrictFilters([ - 'visible' => 1, // Only show visible departments, this is to prevent showing internal system departments to the end-user. - ]) + $this->buildDepartmentListFilters( + $departments_o, + $user->hasPermission('superuser_fetch_department') + ) ) ); } else { @@ -160,6 +217,10 @@ class departmentsRoute if (self::isParametersSet(['order_priority'])) { $department->order_priority->set((int)self::getParameter('order_priority')); } + if (self::isParametersSet(['archived'])) { + $department->archived->set(self::isTruthyBooleanValue(self::getParameter('archived'))); + } + $department->objectChanged(); // Log the incident (new logs_o())->add('departments', (int)self::getParameter('id'), 1, $user->id, 'EDIT_DEPARTMENT', 'Successfully edited a department'); // Return a success message diff --git a/services/nginx/app/routes/errorReportRoute.php b/services/nginx/app/routes/errorReportRoute.php new file mode 100644 index 00000000..7d691f61 --- /dev/null +++ b/services/nginx/app/routes/errorReportRoute.php @@ -0,0 +1,95 @@ +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; + } +} diff --git a/services/nginx/app/routes/guestRoute.php b/services/nginx/app/routes/guestRoute.php index 4259bdd7..13fd7de0 100644 --- a/services/nginx/app/routes/guestRoute.php +++ b/services/nginx/app/routes/guestRoute.php @@ -76,7 +76,8 @@ class guestRoute 'name' => (string)$lane->name->value(), 'status' => (string)$lane->getLaneStatus()->name, 'products' => $lane->getSelfServeLaneProducts(), - 'machine_available' => !empty($lane->relay_machine_id->value()), + 'selfserve_enabled' => $lane->isSelfServeEnabled(), + 'machine_available' => $lane->isSelfServeEnabled() && !empty($lane->relay_machine_id->value()), 'dynamic_image_id' => $lane->dynamic_image_id->value() ? (int)$lane->dynamic_image_id->value() : null, ]; }, $department->getLanes()); @@ -96,4 +97,4 @@ class guestRoute ])), 200); }); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/machineButtonPressRoute.php b/services/nginx/app/routes/machineButtonPressRoute.php index 79693b66..b423eba7 100644 --- a/services/nginx/app/routes/machineButtonPressRoute.php +++ b/services/nginx/app/routes/machineButtonPressRoute.php @@ -4,6 +4,7 @@ namespace routes; use classes\authentication; use classes\selfserve; +use modules\selfserve\classes\selfserve_machine_signal; use modules\selfserve\classes\selfserve_wash_flow; use objects\department_lanes_o; use objects\logs_o; @@ -52,6 +53,60 @@ class machineButtonPressRoute $this->post('/relay/button/press/post', $handler, [ 'add_button_press' => 'Add a button press' ]); + + $shellyHandler = function () { + global $response; + + self::requirePlateScannerAuth(); + $plate_scanner = (new authentication())->get_plate_scanner(); + if (!$plate_scanner) { + $response->error('Invalid plate scanner session', 403); + } + + $payload = $this->getParametersAsArray(); + unset($payload['token']); + $lane_id = self::isParametersSet(['lane_id']) ? (int)self::getParameter('lane_id') : null; + + try { + $result = (new selfserve_machine_signal())->recordCloudShellySignal( + (int)$plate_scanner->department_id->value(), + $lane_id, + $payload, + [ + 'scanner_id' => (int)$plate_scanner->id, + 'scanner_name' => (string)$plate_scanner->name->value(), + ] + ); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 400); + } + + (new logs_o())->add( + 'relay', + $plate_scanner->department_id->value(), + 1, + 0, + 'TRIGGER_MACHINE_ON_SIGNAL', + 'Shelly machine ON signal received from plate scanner: ' . $plate_scanner->id + ); + $response->success([ + 'message' => !empty($result['recorded']) ? 'Machine ON signal recorded.' : 'Shelly signal ignored.', + 'scanner' => $plate_scanner->name->value(), + 'lane_id' => $result['lane_id'] ?? $lane_id, + 'signal' => $result['signal'] ?? null, + 'selfserve' => $result['selfserve'] ?? null, + 'ignored' => $result['ignored'] ?? false, + 'reason' => $result['reason'] ?? null, + ], !empty($result['recorded']) ? 201 : 202); + }; + + $this->get('/relay/machine/on/post', $shellyHandler, [ + 'add_button_press' => 'Record a Shelly machine ON signal' + ]); + + $this->post('/relay/machine/on/post', $shellyHandler, [ + 'add_button_press' => 'Record a Shelly machine ON signal' + ]); } private function resolveLaneId(plate_scanners_o $plateScanner): int diff --git a/services/nginx/app/routes/moduleConfigRoute.php b/services/nginx/app/routes/moduleConfigRoute.php index beb67981..040b5e49 100644 --- a/services/nginx/app/routes/moduleConfigRoute.php +++ b/services/nginx/app/routes/moduleConfigRoute.php @@ -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; diff --git a/services/nginx/app/routes/moduleScannerRoute.php b/services/nginx/app/routes/moduleScannerRoute.php index cc53a85f..1cb3049e 100644 --- a/services/nginx/app/routes/moduleScannerRoute.php +++ b/services/nginx/app/routes/moduleScannerRoute.php @@ -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 ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/moduleSelfServeRoute.php b/services/nginx/app/routes/moduleSelfServeRoute.php index 2433075a..c5c68048 100644 --- a/services/nginx/app/routes/moduleSelfServeRoute.php +++ b/services/nginx/app/routes/moduleSelfServeRoute.php @@ -8,10 +8,15 @@ use classes\response; use classes\router; use classes\selfserve; use classes\stripe; +use modules\selfserve\classes\selfserve_lane; +use modules\selfserve\classes\selfserve_wash_flow; use modules\selfserve\helpers\selfserve_lane_command; use modules\selfserve\helpers\selfserve_lane_port; use modules\selfserve\helpers\selfserve_lane_relay; +use modules\selfserve\helpers\selfserve_lane_state; +use modules\selfserve\helpers\selfserve_lane_status; use modules\selfserve\helpers\selfserve_wash_session_status; +use objects\department_lanes_o; use objects\departments_o; use objects\logs_o; use objects\orders_o; @@ -25,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 */ @@ -55,14 +62,68 @@ 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; - self::requirePermission('modules_selfserve_lane_wash_in_progress_view'); self::requireParameters(['lane_id']); $lane_id = (int)$this->getParameter('lane_id'); self::requireType($lane_id, self::type_int()); self::requireMinValue($lane_id, 1); + $customer_scope = $this->requireInProgressWashDetailsAccess(); $build_customer = static function (?int $customer_number): ?array { if ($customer_number === null || $customer_number <= 0) { @@ -165,13 +226,13 @@ class moduleSelfServeRoute || $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH); if (!$in_progress) { - $response->success([ + $response->success($this->scopeInProgressWashResponseForCustomer([ 'lane_id' => $lane_id, 'in_progress' => false, 'session' => null, 'customer' => null, 'vehicle' => null, - ]); + ], $customer_scope)); return; } @@ -186,7 +247,7 @@ class moduleSelfServeRoute $customer = $build_customer($runtime_customer_number); $vehicle = $build_vehicle(null, $runtime_reg); - $response->success([ + $response->success($this->scopeInProgressWashResponseForCustomer([ 'lane_id' => $lane_id, 'in_progress' => true, 'session' => [ @@ -208,7 +269,7 @@ class moduleSelfServeRoute ], 'customer' => $customer, 'vehicle' => $vehicle, - ]); + ], $customer_scope)); return; } @@ -240,7 +301,7 @@ class moduleSelfServeRoute ]; $in_progress = in_array($status, $in_progress_statusses); - $response->success([ + $response->success($this->scopeInProgressWashResponseForCustomer([ 'lane_id' => $lane_id, 'status' => (string)$session->status->value(), 'in_progress' => $in_progress, @@ -264,17 +325,126 @@ class moduleSelfServeRoute ], 'customer' => $customer, 'vehicle' => $vehicle, - ]); + ], $customer_scope)); }, [ 'modules_selfserve_lane_wash_in_progress_view' => 'View customer and vehicle details for an in-progress self-serve wash on a lane', + 'list_own_department_selfserve_vehicle_conditions' => 'View in-progress self-serve wash details for the authenticated customer', + ] + ); + + /** Modules > Self Serve > Sessions */ + $this->get('/modules/self-serve/sessions', function () { + global $response; + self::requirePermission('modules_selfserve_sessions_view'); + + $sessions = new selfserve_wash_sessions_o(); + $sessions->setSearchableFields([ + 'id', + 'lane_id', + 'department_id', + 'machine_type_id', + 'customer_number', + 'vehicle_id', + 'vehicle_type_id', + 'reg', + 'status', + 'order_id', + 'created_at', + 'completed_at', + ]); + + $additional_where = null; + if ($this->requestedBoolean('open_only', false) || $this->requestedBoolean('active_only', false)) { + $additional_where = '`completed_at` IS NULL AND `status` NOT IN (' . selfserve_wash_sessions_o::terminalStatusSqlList() . ')'; + } + + $response->success($sessions->listObjectsWithPaginationIfSet( + function (array $row): array { + $session = (new selfserve_wash_sessions_o())->select((int)$row['id']); + if (!$session->exists()) { + return $row; + } + + return [ + ...$session->asArray(), + 'elapsed_minutes' => $session->getElapsedMinutes(), + 'open' => $session->isOpen(), + ]; + }, + null, + [], + $additional_where + )); + }, + [ + 'modules_selfserve_sessions_view' => 'View self-serve wash sessions', + ] + ); + + /** Modules > Self Serve > Session detail */ + $this->get('/modules/self-serve/sessions/{id}', function () { + global $response; + self::requirePermission('modules_selfserve_sessions_view'); + $session_id = (int)$this->fromRoute('id'); + self::requireType($session_id, self::type_int()); + self::requireMinValue($session_id, 1); + + try { + $response->success((new selfserve_wash_flow())->getSessionSummary($session_id)); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 404); + } + }, + [ + 'modules_selfserve_sessions_view' => 'View self-serve wash session details', + ] + ); + + /** Modules > Self Serve > Lane > Force > Stop */ + $this->post('/modules/self-serve/lane/force/stop', function () { + global $response; + self::requirePermission('modules_selfserve_sessions_force_stop'); + self::requireParameters(['lane_id', 'bill']); + + $lane_id = (int)$this->getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + + $session_id = null; + if ($this->isParametersSet(['session_id']) && $this->getParameter('session_id') !== null && $this->getParameter('session_id') !== '') { + $session_id = (int)$this->getParameter('session_id'); + self::requireType($session_id, self::type_int()); + self::requireMinValue($session_id, 1); + } + + $bill = $this->requestedBoolean('bill'); + if ($bill) { + self::requirePermission('modules_selfserve_sessions_force_stop_bill'); + } + $reason = null; + if ($this->isParametersSet(['reason'])) { + $reason = trim((string)$this->getParameter('reason')); + $reason = $reason === '' ? null : $reason; + } + $user = (new authentication())->get_user(); + $user_id = $user instanceof users_o ? (int)$user->id : null; + + try { + $response->success((new selfserve_wash_flow())->forceStopLane($lane_id, $session_id, $bill, $reason, $user_id)); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 409); + } + }, + [ + 'modules_selfserve_sessions_force_stop' => 'Force stop a self-serve wash session without relay or gate signaling', + 'modules_selfserve_sessions_force_stop_bill' => 'Bill elapsed minutes when force stopping a self-serve wash session', ] ); /** 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(); @@ -297,37 +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: - self::requirePermission('modules_selfserve_lane_command_execute_open_property_access_gate'); + $this->requirePropertyGateCommandPermission( + 'modules_selfserve_lane_command_execute_open_property_access_gate', + $lane, + $customer_number + ); break; case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE: - self::requirePermission('modules_selfserve_lane_command_execute_open_property_exit_gate'); + $this->requirePropertyGateCommandPermission( + 'modules_selfserve_lane_command_execute_open_property_exit_gate', + $lane, + $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([ @@ -370,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']); @@ -393,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; @@ -412,8 +621,7 @@ class moduleSelfServeRoute } // Persist on lane cache (overwrites previous allowed services) try { - $this->applyShellyTransportOverride($lane); - $relay_sync = $lane->syncMachineRelayFromVisibleServices($allowed_services, true); + $relay_sync = $lane->setAllowedServicesFromVisibleTasks($allowed_services); $response->success([ 'lane_id' => $lane_id, 'allowed_services' => $allowed_services, @@ -713,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']); @@ -726,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); @@ -1021,6 +1230,83 @@ class moduleSelfServeRoute ]); } + private function requireInProgressWashDetailsAccess(): ?int + { + global $response; + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Authentication failed. Invalid or missing token.', 401); + } + + if (self::hasPermission('modules_selfserve_lane_wash_in_progress_view')) { + return null; + } + + if (self::hasPermission('list_own_department_selfserve_vehicle_conditions')) { + $customer_number = $this->resolveEffectiveCustomerNumber(); + if ($customer_number !== null && $customer_number > 0) { + return (int)$customer_number; + } + } + + $this->emitForbidden([ + 'modules_selfserve_lane_wash_in_progress_view', + 'list_own_department_selfserve_vehicle_conditions', + ]); + return null; + } + + /** + * Customers poll all visible lanes to restore their own active wash. Keep that + * poll successful without exposing another customer's session details. + * + * @param array $payload + * @return array + */ + protected function scopeInProgressWashResponseForCustomer(array $payload, ?int $customer_number): array + { + if ($customer_number === null || $customer_number <= 0 || ($payload['in_progress'] ?? false) !== true) { + return $payload; + } + + $session_customer_number = $this->extractInProgressWashCustomerNumber($payload); + if ($session_customer_number === $customer_number) { + return $payload; + } + + return [ + 'lane_id' => (int)($payload['lane_id'] ?? 0), + 'in_progress' => true, + 'session' => null, + 'customer' => null, + 'vehicle' => null, + ]; + } + + /** + * @param array $payload + */ + private function extractInProgressWashCustomerNumber(array $payload): ?int + { + $candidates = [ + $payload['session']['customer_number'] ?? null, + $payload['customer']['customer_number'] ?? null, + ]; + + foreach ($candidates as $candidate) { + if ($candidate === null || $candidate === '') { + continue; + } + $customer_number = (int)$candidate; + if ($customer_number > 0) { + return $customer_number; + } + } + + return null; + } + /** * @param array $status * @param array $extra @@ -1072,6 +1358,211 @@ class moduleSelfServeRoute $lane->setShellyTransportOverride($transport); } + /** + * @param array $permissions + */ + private function hasAllPermissions(array $permissions): bool + { + foreach ($permissions as $permission) { + if (!$this->hasPermission($permission)) { + return false; + } + } + + return true; + } + + /** + * @param array $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 + { + $elevated_permissions = [ + 'modules_selfserve_lane_command_execute', + $permission, + ]; + if ($this->hasAllPermissions($elevated_permissions)) { + return; + } + + if ($this->canCustomerUsePropertyGateForLane($lane, $customer_number)) { + return; + } + + $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 + { + 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 = $this->departmentIdForLane($lane); + if ($department_id <= 0) { + return false; + } + + 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 + { + if ($department_id <= 0 || $customer_number <= 0) { + return false; + } + + $active_statuses = array_map( + static fn(selfserve_wash_session_status $status): string => $status->value, + [ + selfserve_wash_session_status::MACHINE_RELAY_ENABLED, + selfserve_wash_session_status::READY_FOR_MACHINE_START, + selfserve_wash_session_status::MACHINE_STARTED, + selfserve_wash_session_status::PENDING_QUESTIONS, + selfserve_wash_session_status::MACHINE_NOT_ALLOWED, + ] + ); + + $sessions = (new selfserve_wash_sessions_o())->getFieldsWhere([ + 'department_id' => $department_id, + 'customer_number' => $customer_number, + 'completed_at' => null, + 'deleted_at' => null, + ], ['id', 'status']); + + foreach ($sessions as $session) { + if (in_array((string)($session['status'] ?? ''), $active_statuses, true)) { + return true; + } + } + + foreach ((new department_lanes_o())->getDepartmentLanes($department_id) as $department_lane) { + try { + $lane = (new selfserve())->lane((int)$department_lane->id); + if ((int)$lane->getCustomerNumber() !== $customer_number) { + continue; + } + + if ( + $lane->getLaneStatus()->equals(selfserve_lane_status::OCCUPIED) + || $lane->getLaneState()->equals(selfserve_lane_state::IN_WASH) + ) { + return true; + } + } catch (\Throwable) { + continue; + } + } + + return false; + } + private function requestedShellyTransportOverride(): ?string { $transport = null; @@ -1111,4 +1602,48 @@ class moduleSelfServeRoute self::requireMinValue($toggle_after, 1); 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])) { + return $default; + } + + $value = self::getParameter($parameter); + if (is_bool($value)) { + return $value; + } + if (is_int($value)) { + return $value === 1; + } + $normalized = strtolower(trim((string)$value)); + return in_array($normalized, ['1', 'true', 'yes', 'on'], true); + } } diff --git a/services/nginx/app/routes/moduleWeatherAPIRoute.php b/services/nginx/app/routes/moduleWeatherAPIRoute.php index e61a5cd9..b57885ee 100644 --- a/services/nginx/app/routes/moduleWeatherAPIRoute.php +++ b/services/nginx/app/routes/moduleWeatherAPIRoute.php @@ -3,10 +3,12 @@ namespace routes; use classes\authentication; +use classes\redis; 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; @@ -155,6 +157,42 @@ class moduleWeatherAPIRoute 'department_access_:id' => 'Access weather timeline for one or more specific departments', ]); + $this->get('/departments/weather/hours/details', function () { + global $response; + self::requirePermission('departments_weather_get'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $department_ids = self::parseDepartmentIdsFromRequest(); + foreach ($department_ids as $department_id) { + self::requireDepartmentAccess((string)$department_id); + } + + $slot = self::parseDepartmentWeatherHourSlotFromRequest(); + $departments = self::loadDepartmentsByIds($department_ids); + $departments_with_employees = $this->loadDepartmentWeatherEmployeeHourDetailsByDepartment($departments, $slot['slotStart']); + $department_context = count($department_ids) === 1 ? (string)$department_ids[0] : implode(',', $department_ids); + $total_hours = 0.0; + foreach ($departments_with_employees as $department) { + $total_hours += (float)($department['hours'] ?? 0.0); + } + + (new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_HOUR_DETAILS_GET', 'Department weather hour details fetched'); + $response->success([ + 'date' => $slot['date'], + 'time' => $slot['time'], + 'slot' => $slot['slotKey'], + 'hours' => round($total_hours, 2), + 'departments' => $departments_with_employees, + ], 200); + }, [ + 'departments_weather_get' => 'Get department weather employee hour details for one hour slot', + 'department_access_:id' => 'Access weather hour details for one or more specific departments', + ]); + $this->get('/departments/weather/targets', function () { global $response; self::requirePermission('departments_weather_targets_get'); @@ -819,6 +857,45 @@ class moduleWeatherAPIRoute ]; } + private function parseDepartmentWeatherHourSlotFromRequest(): array + { + global $response; + + self::requireParameters(['date', 'time']); + $date_value = self::getParameter('date'); + $time_value = self::getParameter('time'); + if (!is_string($date_value) || !is_string($time_value)) { + $response->error('Invalid type. Expected: string for date/time', 400); + } + + $date = trim((string)$date_value); + $time = trim((string)$time_value); + self::requireDateFormat($date, self::FORMAT_DATE()); + if (!preg_match('/^\d{2}:\d{2}(?::\d{2})?$/', $time)) { + $response->error('Invalid time format. Expected HH:MM', 400); + } + + $time_parts = explode(':', $time); + $hour = (int)($time_parts[0] ?? -1); + $minute = (int)($time_parts[1] ?? -1); + if ($hour < 0 || $hour > 23 || $minute < 0 || $minute > 59) { + $response->error('Invalid time value', 400); + } + + $normalized_time = sprintf('%02d:%02d', $hour, $minute); + $slot_start = new DateTime($date . ' ' . $normalized_time . ':00'); + $slot_end = clone $slot_start; + $slot_end->add(new DateInterval('PT1H')); + + return [ + 'date' => $date, + 'time' => $normalized_time, + 'slotKey' => $slot_start->format('Y-m-d H:00'), + 'slotStart' => $slot_start, + 'slotEnd' => $slot_end, + ]; + } + private function loadDepartmentsByIds(array $department_ids): array { $departments = []; @@ -1408,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; @@ -1438,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; @@ -1450,7 +1527,7 @@ class moduleWeatherAPIRoute return []; } - private function extractWorkfeedDepartmentId(mixed $shift): ?string + private static function extractWorkfeedDepartmentId(mixed $shift): ?string { $record = self::normalizeWorkfeedRecord($shift); @@ -1469,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); @@ -1523,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; @@ -1551,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); @@ -1564,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; @@ -1583,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; @@ -1604,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; @@ -1635,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, @@ -1688,6 +1765,515 @@ class moduleWeatherAPIRoute return round($hours, 2); } + /** + * @throws Exception + */ + private function loadDepartmentWeatherEmployeeHourDetailsByDepartment(array $departments, DateTime $slot_start): array + { + try { + $workfeed = new workfeed(); + $employeeNameCache = null; + if (defined('redis')) { + try { + $employeeNameCache = new redis(); + } catch (Exception $e) { + // Redis is unavailable, proceed without caching. + } + } + + $workfeed_department_ids_by_department = $this->resolveWorkfeedDepartmentIdsByDepartmentId($departments, $workfeed); + if ($workfeed_department_ids_by_department === []) { + return []; + } + + $slot_end = clone $slot_start; + $slot_end->add(new DateInterval('PT1H')); + $query_start = clone $slot_start; + $query_start->sub(new DateInterval('P1D')); + + $shifts_response = $workfeed->listShifts([ + 'startFrom' => $query_start->format(DateTime::ATOM), + 'startTo' => $slot_end->format(DateTime::ATOM), + ]); + $shifts = self::normalizeWorkfeedCollection($shifts_response); + if ($shifts === []) { + return []; + } + + $occurred_until = new DateTime(); + $resolved_employee_names_by_id = []; + $details = []; + foreach ($departments as $department) { + $department_id = (int)($department->id ?? 0); + if ($department_id < 1) { + continue; + } + + $workfeed_department_ids = $workfeed_department_ids_by_department[$department_id] ?? null; + if ($workfeed_department_ids === null) { + continue; + } + + $employees = self::calculateWorkfeedEmployeeHoursForHourByEmployee( + $shifts, + $workfeed_department_ids, + $slot_start, + $occurred_until + ); + $employees = $this->resolveMissingWorkfeedEmployeeNames( + $employees, + $employeeNameCache, + $resolved_employee_names_by_id, + $workfeed + ); + if ($employees === []) { + continue; + } + + $department_hours = 0.0; + foreach ($employees as $employee) { + $department_hours += (float)($employee['hours'] ?? 0.0); + } + + $details[] = [ + 'department_id' => $department_id, + 'department_name' => trim((string)($department->name ?? '')) ?: ('Department ' . $department_id), + 'hours' => round($department_hours, 2), + 'employees' => $employees, + ]; + } + + usort($details, static function (array $left, array $right): int { + return strcasecmp((string)($left['department_name'] ?? ''), (string)($right['department_name'] ?? '')); + }); + + return $details; + } catch (Exception) { + return []; + } + } + + private static function calculateWorkfeedEmployeeHoursForHourByEmployee( + array $shifts, + string|array $workfeed_department_ids, + DateTime $slot_start, + ?DateTime $occurred_until = null + ): array + { + $department_id_values = is_array($workfeed_department_ids) ? $workfeed_department_ids : [$workfeed_department_ids]; + $department_id_lookup = []; + foreach ($department_id_values as $department_id_value) { + $normalized = trim((string)$department_id_value); + if ($normalized !== '') { + $department_id_lookup[$normalized] = true; + } + } + if ($department_id_lookup === []) { + return []; + } + + $slot_end = clone $slot_start; + $slot_end->add(new DateInterval('PT1H')); + $slot_start_ts = $slot_start->getTimestamp(); + $slot_end_ts = $slot_end->getTimestamp(); + $occurred_until_ts = ($occurred_until ?? new DateTime())->getTimestamp(); + + $hours_by_employee_key = []; + foreach ($shifts as $shift) { + $shift_department_id = self::extractWorkfeedDepartmentId($shift); + if ($shift_department_id === null || !isset($department_id_lookup[$shift_department_id])) { + continue; + } + + $timing = workfeed_shift_time_resolver::resolveShiftTiming($shift); + if ($timing === null) { + continue; + } + + $shift_start_ts = $timing['actualStart']->getTimestamp(); + $shift_end_ts = min($timing['actualEnd']->getTimestamp(), $occurred_until_ts); + if ($shift_end_ts <= $shift_start_ts) { + continue; + } + + $overlap_start = max($slot_start_ts, $shift_start_ts); + $overlap_end = min($slot_end_ts, $shift_end_ts); + if ($overlap_end <= $overlap_start) { + continue; + } + + $employee_identity = self::extractWorkfeedEmployeeIdentity($shift); + $employee_id = $employee_identity['id']; + $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] = [ + 'employee_id' => $employee_id, + 'employee_name' => $employee_name, + 'hours' => 0.0, + ]; + } + + $hours_by_employee_key[$employee_key]['hours'] += ($overlap_end - $overlap_start) / 3600; + } + + foreach ($hours_by_employee_key as &$employee) { + $employee['hours'] = round((float)$employee['hours'], 2); + } + unset($employee); + + usort($hours_by_employee_key, static function (array $left, array $right): int { + return strcasecmp((string)($left['employee_name'] ?? ''), (string)($right['employee_name'] ?? '')); + }); + + return array_values(array_filter($hours_by_employee_key, static function (array $employee): bool { + return (float)($employee['hours'] ?? 0.0) > 0; + })); + } + + 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) { + continue; + } + + $employee_name = self::normalizeShiftTextValue($employee['employee_name'] ?? null); + if (!self::isMissingEmployeeDisplayName($employee_name, $employee_id)) { + continue; + } + + if (!array_key_exists($employee_id, $resolved_names_by_employee_id)) { + $resolved_names_by_employee_id[$employee_id] = $this->fetchCachedWorkfeedEmployeeDisplayName( + $employee_name_cache, + $employee_id + ); + } + + 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; + } + } + unset($employee); + + usort($employees, static function (array $left, array $right): int { + return strcasecmp((string)($left['employee_name'] ?? ''), (string)($right['employee_name'] ?? '')); + }); + + 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 $employee_ids + * @return array + */ + 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 $employee_id_lookup + * @param array $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 + */ + 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 + { + if ($employee_name_cache === null) { + return null; + } + + try { + $employee_name = $employee_name_cache->get_workfeed_employee_name($employee_id); + } catch (Exception) { + return null; + } + + return self::isMissingEmployeeDisplayName($employee_name, $employee_id) ? null : $employee_name; + } + + private static function isMissingEmployeeDisplayName(?string $employee_name, ?string $employee_id = null): bool + { + return workfeed_employee_name_formatter::isMissingDisplayName($employee_name, $employee_id); + } + + private static function extractWorkfeedEmployeeIdentity(mixed $shift): array + { + $record = self::normalizeWorkfeedRecord($shift); + + $employee_id = null; + foreach ([ + 'employeeID', + 'employeeId', + 'employee_id', + 'employee.id', + 'employee.employeeID', + 'employee.employeeId', + 'employee.employee_id', + 'employee.uuid', + 'employeeUUID', + 'employee_uuid', + 'user.id', + 'userId', + ] as $path) { + $value = self::normalizeShiftTextValue(self::getNestedRecordValue($record, $path)); + if ($value !== null) { + $employee_id = $value; + break; + } + } + + $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', + '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 [ + 'id' => $employee_id, + 'name' => $employee_name, + ]; + } + + private static function normalizeShiftTextValue(mixed $value): ?string + { + if (!is_scalar($value)) { + return null; + } + + $normalized = trim((string)$value); + if ($normalized === '') { + return null; + } + + return $normalized; + } + /** * @throws Exception */ diff --git a/services/nginx/app/routes/moduleXLVaskRoute.php b/services/nginx/app/routes/moduleXLVaskRoute.php index d8b52a6d..9070abe8 100644 --- a/services/nginx/app/routes/moduleXLVaskRoute.php +++ b/services/nginx/app/routes/moduleXLVaskRoute.php @@ -2,10 +2,13 @@ namespace routes; +require_once WD . '/classes/xlvask_automation_service.php'; + use classes\authentication; use classes\response; use classes\router; use classes\xlvask; +use classes\xlvask_automation_service; use objects\orders_o; use objects\users_o; use objects\xlvask_customers_o; @@ -256,6 +259,7 @@ class moduleXLVaskRoute $xlvask_usage_logs_o = new \objects\xlvask_usage_logs_o(); // Import usage logs $xlvask_usage_logs_o->importUsageLogs(); + (new xlvask_automation_service())->runPending(null, null, [], 100, null); // Response $response->success( 'Usage logs imported', @@ -267,4 +271,4 @@ class moduleXLVaskRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/optionsRoute.php b/services/nginx/app/routes/optionsRoute.php index 7f35bb0a..b568c078 100644 --- a/services/nginx/app/routes/optionsRoute.php +++ b/services/nginx/app/routes/optionsRoute.php @@ -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']; }); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/orderBookingRoute.php b/services/nginx/app/routes/orderBookingRoute.php index c7b672cc..e2e784f0 100644 --- a/services/nginx/app/routes/orderBookingRoute.php +++ b/services/nginx/app/routes/orderBookingRoute.php @@ -12,6 +12,7 @@ use objects\departments_o; use objects\logs_o; use objects\order_bookings_o; use objects\order_items_o; +use objects\orders_o; use objects\products_o; use objects\users_o; use traits\route_t; @@ -265,6 +266,7 @@ class orderBookingRoute $po = self::getTargetPo(false); // String | Null $pickup = self::getTargetPickup(false); // Bool | Null $items = self::getTargetItems(false); // Array of order_items_o objects + $order_id_was_set = self::isParametersSet(['order_id']); $order_id = self::getTargetOrderId(false); // Int | Null /** Authentication */ $auth = new authentication(); @@ -293,6 +295,7 @@ class orderBookingRoute /** * Update the object */ + $previous_order_id = (int)($object->order_id->value() ?? 0); $data = [ ...(self::hasPermission($permission_other) && !empty($customer_number) ? [ 'customer_number' => (int)$customer_number->customer_number->value(), @@ -307,9 +310,12 @@ class orderBookingRoute ...(isset($po) ? ['po' => $po] : []), ...(isset($pickup) ? ['pickup' => $pickup] : []), ...(isset($items) ? ['items' => $items] : []), - ...(isset($order_id) ? ['order_id' => $order_id] : []), + ...($order_id_was_set ? ['order_id' => $order_id] : []), ]; $object->update($data); + if ($order_id_was_set && $order_id === null) { + self::clearMatchingOrderBookingLink($object, $previous_order_id); + } /** * Return the object */ @@ -369,7 +375,7 @@ class orderBookingRoute * Parameters */ $object = self::getTargetObject(); - $safetySeal = self::getSafetySeal(false); // Int | Null + $safetySeal = self::getSafetySeal(false); /** * Authentication */ @@ -381,6 +387,9 @@ class orderBookingRoute if (!$object || !$object->exists()) { $response->error('Order booking does not exist.', 400); } + if (!(int)$object->order_id->value()) { + $response->error('Booking completion must be completed through POS desktop or mobile steps.', 409); + } /** * Complete the booking */ @@ -502,6 +511,37 @@ class orderBookingRoute $error = 'Invalid safety seal'; if (!$required && !$this->isParametersSet([$parameter])) return null; self::requireParameters([$parameter]); + $rawValue = self::getParameter($parameter); + if (!$required && $rawValue === null) return null; + if (is_string($rawValue)) { + $rawValue = trim($rawValue); + if (!$required && $rawValue === '') return null; + if (!ctype_digit($rawValue)) $response->error($error, 400); + } elseif (!is_int($rawValue)) { + if ($required) { + self::requireType($rawValue, self::type_int()); + } else { + self::requireTypeIn($rawValue, [self::type_int(), self::type_null()]); + } + } + if ($required || $rawValue !== null) { + $valueLength = strlen((string)$rawValue); + if ($valueLength < 1) $response->error('Parameter ' . $parameter . ' must be at least 1 characters long', 400); + if ($valueLength > 9) $response->error('Parameter ' . $parameter . ' must be at most 9 characters long', 400); + } + $value = (int)$rawValue; + self::requireMinValue($value, 1); + self::requireMaxValue($value, 999999999); + return $value; + } + + private function getTargetOrderId(bool $required = true): int|null + { + global $response; + $parameter = 'order_id'; + $error = 'Invalid order ID'; + if (!$required && !$this->isParametersSet([$parameter])) return null; + self::requireParameters([$parameter]); if ($required) { self::requireType(self::getParameter($parameter), self::type_int()); } else { @@ -517,26 +557,26 @@ class orderBookingRoute return $value; } - private function getTargetOrderId(bool $required = true): int|string|null + /** + * @throws Exception + */ + private function clearMatchingOrderBookingLink(order_bookings_o $booking, int $previous_order_id): void { - global $response; - $parameter = 'order_id'; - $error = 'Invalid order ID'; - if (!$required && !$this->isParametersSet([$parameter])) return null; - self::requireParameters([$parameter]); - if ($required) { - self::requireType(self::getParameter($parameter), self::type_int()); - } else { - self::requireTypeIn(self::getParameter($parameter), [self::type_int(), self::type_null()]); - // Check if the value is null - if ($this->getParameter($parameter) === null) return "null"; + if ($previous_order_id <= 0) { + return; } - self::requireMinLength($parameter, 1); - self::requireMaxLength($parameter, 9); - $value = (int)self::getParameter($parameter); - self::requireMinValue($value, 1); - self::requireMaxValue($value, 999999999); - return $value; + + $previous_order = (new orders_o())->select($previous_order_id); + if (!$previous_order->exists()) { + return; + } + + if ((int)($previous_order->booking_id->value() ?? 0) !== (int)$booking->id) { + return; + } + + $previous_order->booking_id->set(null); + $previous_order->objectChanged(); } private function getTargetCustomer(bool $required = true): users_o|null diff --git a/services/nginx/app/routes/orderInvoicesRoute.php b/services/nginx/app/routes/orderInvoicesRoute.php index 7e71cbd5..01da8864 100644 --- a/services/nginx/app/routes/orderInvoicesRoute.php +++ b/services/nginx/app/routes/orderInvoicesRoute.php @@ -9,6 +9,7 @@ use classes\economic_transfer_queue_details_summary; use classes\economic_v2_compare_engine; use classes\economic_v2_line_normalizer; use classes\economic_v2_revenue_statistics_service; +use classes\invoicing_period_utils; use classes\response; use classes\router; use Exception; @@ -525,6 +526,123 @@ class orderInvoicesRoute ] ); + /** Collected order invoices > Split by month > POST */ + $this->post('/collected-invoices/split-by-month', function () { + global $response, $db; + self::requirePermission('split_collected_invoice'); + $user = (new authentication())->get_user(); + if ($user) { + self::requireParameters(['dateFrom', 'dateTo']); + + try { + $date_range = invoicing_period_utils::normalizeDateRange( + (string)self::getParameter('dateFrom'), + (string)self::getParameter('dateTo') + ); + } catch (\InvalidArgumentException $e) { + $response->error($e->getMessage(), 400); + } + + $preview = false; + if (self::isParametersSet(['preview'])) { + $preview_raw = self::getParameter('preview'); + if (is_bool($preview_raw)) { + $preview = $preview_raw; + } elseif (is_numeric($preview_raw)) { + $preview = ((int)$preview_raw) === 1; + } elseif (is_string($preview_raw)) { + $normalized_preview = strtolower(trim($preview_raw)); + if (!in_array($normalized_preview, ['true', 'false', '1', '0'], true)) { + $response->error('preview must be a boolean', 400); + } + $preview = in_array($normalized_preview, ['true', '1'], true); + } else { + $response->error('preview must be a boolean', 400); + } + } + (new logs_o())->add( + 'orderInvoices', + 'global', + 1, + $user->id, + $preview ? 'PREVIEW_SPLIT_COLLECTED_INVOICE_BY_MONTH' : 'SPLIT_COLLECTED_INVOICE_BY_MONTH', + $preview ? 'User previewed splitting collected order invoices by month' : 'User split collected order invoices by order month' + ); + + $date_from = $db->escape_string($date_range['dateFrom']); + $date_to = $db->escape_string($date_range['dateTo']); + $sql = "SELECT DISTINCT invoice_collection_id + FROM orders + WHERE created_at BETWEEN '$date_from' AND '$date_to' + AND invoice_collection_id IS NOT NULL + AND invoice_collection_id > 0 + AND deleted_at IS NULL"; + $query_result = $db->query($sql); + $invoice_collection_ids = []; + while ($row = $query_result->fetch_assoc()) { + $invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0); + if ($invoice_collection_id > 0) { + $invoice_collection_ids[] = $invoice_collection_id; + } + } + + $items = []; + $changed = []; + $skipped = []; + foreach ( array_values(array_unique($invoice_collection_ids)) as $invoice_collection_id ) { + try { + $collected_order_invoices = (new collected_order_invoices_o())->select($invoice_collection_id); + $collected_order_invoices->requireSelected(); + $split_result = $preview + ? $collected_order_invoices->previewSplitByOrderMonth() + : $collected_order_invoices->splitByOrderMonth(); + $item = [ + 'invoice_collection_id' => $invoice_collection_id, + ...$split_result, + ]; + if (($split_result['status'] ?? '') === 'changed') { + $changed[] = $item; + } else { + $skipped[] = $item; + } + $items[] = $item; + } catch (\Throwable $e) { + $item = [ + 'status' => 'skipped', + 'invoice_collection_id' => $invoice_collection_id, + 'preview' => $preview, + 'reason' => 'not_splittable', + 'message' => $e->getMessage(), + ]; + $skipped[] = $item; + $items[] = $item; + } + } + + $response->success([ + 'message' => $preview + ? 'Collected invoice monthly split preview completed' + : 'Collected invoice monthly split completed', + 'preview' => $preview, + 'dateFrom' => $date_range['dateFrom'], + 'dateTo' => $date_range['dateTo'], + 'processed_count' => count($items), + 'changed_count' => count($changed), + 'skipped_count' => count($skipped), + 'changed' => $changed, + 'skipped' => $skipped, + 'items' => $items, + ]); + } else { + (new logs_o())->add('orderInvoices', 'global', 0, 0, 'SPLIT_COLLECTED_INVOICE_BY_MONTH', 'User tried to split collected order invoices by month without a valid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'split_collected_invoice' => 'Split collected order invoices by order month. This is a superuser-only route.' + ] + ); + /** Collected order invoices > E-Conomic > POST (queued) */ $this->post('/collected-invoices/economic', function () { global $response; @@ -676,6 +794,29 @@ class orderInvoicesRoute ] ); + $this->get('/collected-invoices/economic/queue/monitor', function () { + global $response; + self::requirePermission('add_collected_invoice_economic'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + $limit = $this->parseCollectedInvoiceQueueMonitorLimit(); + $queue = new economic_transfer_queue(); + + $response->success($this->buildCollectedInvoiceQueueMonitorPayload( + $queue, + (int)$user->id, + $limit + )); + }, + [ + 'add_collected_invoice_economic' => 'Monitor visible collected invoice transfer queue jobs.' + ] + ); + $this->post('/collected-invoices/economic/queue/retry', function () { global $response; self::requirePermission('add_collected_invoice_economic'); @@ -710,6 +851,67 @@ class orderInvoicesRoute ] ); + $this->post('/collected-invoices/economic/queue/dismiss', function () { + global $response; + self::requirePermission('add_collected_invoice_economic'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + $job_id = $this->requireCollectedInvoiceQueueJobId(); + $job = $this->requireCollectedInvoiceQueueJobById($job_id); + $status = strtoupper((string)($job['status'] ?? '')); + if (!in_array($status, [ + economic_transfer_queue::STATUS_COMPLETED, + economic_transfer_queue::STATUS_FAILED, + ], true)) { + $response->error('Only completed or failed collected invoice queue jobs can be cleared', 409); + } + + try { + $queue = new economic_transfer_queue(); + $dismissed = $queue->dismissTerminalJobForUser($job_id, (int)$user->id); + } catch (\Throwable $e) { + $response->error('Failed to clear collected invoice queue job: ' . $e->getMessage(), 400); + } + + $response->success([ + 'message' => 'Collected invoice queue job cleared', + 'job' => $this->withCollectedInvoiceQueueDetailsSummary($dismissed), + ]); + }, + [ + 'add_collected_invoice_economic' => 'Clear one completed or failed queued collected invoice transfer job for the current user.' + ] + ); + + $this->post('/collected-invoices/economic/queue/dismiss-terminal', function () { + global $response; + self::requirePermission('add_collected_invoice_economic'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + $queue = new economic_transfer_queue(); + $dismissed_count = $queue->dismissTerminalJobsForUser( + (int)$user->id, + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT + ); + + $response->success([ + 'message' => 'Completed and failed collected invoice queue jobs cleared', + 'dismissed_count' => $dismissed_count, + ]); + }, + [ + 'add_collected_invoice_economic' => 'Clear all visible completed or failed queued collected invoice transfer jobs for the current user.' + ] + ); + $this->post('/collected-invoices/economic/queue/run', function () { global $response; self::requirePermission('add_collected_invoice_economic'); @@ -1916,6 +2118,25 @@ class orderInvoicesRoute ]; } + private function parseCollectedInvoiceQueueMonitorLimit(): int + { + global $response; + + $limit = 50; + if (self::isParametersSet(['limit'])) { + $limit_raw = self::getParameter('limit'); + if (!is_numeric($limit_raw)) { + $response->error('limit must be between 1 and 100', 400); + } + $limit = (int)$limit_raw; + if ($limit < 1 || $limit > 100) { + $response->error('limit must be between 1 and 100', 400); + } + } + + return $limit; + } + private function requireCollectedInvoiceQueueJobId(): int { global $response; @@ -1986,6 +2207,62 @@ class orderInvoicesRoute ]; } + private function buildCollectedInvoiceQueueMonitorPayload(economic_transfer_queue $queue, int $user_id, int $limit): array + { + $jobs = $queue->listMonitorJobsForUser( + $user_id, + $limit, + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT + ); + $jobs = $this->withCollectedInvoiceQueueDetailsSummaryList($jobs); + + $counts = [ + 'queued' => 0, + 'in_progress' => 0, + 'failed' => 0, + 'completed' => 0, + 'total' => count($jobs), + ]; + $progress_sum = 0; + + foreach ($jobs as $job) { + $status = strtoupper((string)($job['status'] ?? '')); + $job_progress = max(0, min(100, (int)($job['progress_percent'] ?? 0))); + + if ($status === economic_transfer_queue::STATUS_QUEUED) { + $counts['queued']++; + $progress_sum += 0; + continue; + } + + if ($status === economic_transfer_queue::STATUS_PROCESSING) { + $counts['in_progress']++; + $progress_sum += $job_progress; + continue; + } + + if ($status === economic_transfer_queue::STATUS_FAILED) { + $counts['failed']++; + $progress_sum += 100; + continue; + } + + if ($status === economic_transfer_queue::STATUS_COMPLETED) { + $counts['completed']++; + $progress_sum += 100; + } + } + + return [ + 'jobs' => $jobs, + 'counts' => $counts, + 'progress_percent' => $counts['total'] > 0 + ? (int)round($progress_sum / $counts['total']) + : 0, + 'limit' => $limit, + ]; + } + private function countCollectedInvoiceQueueJobs(economic_transfer_queue $queue, array $statuses): int { global $db; @@ -2025,6 +2302,13 @@ class orderInvoicesRoute private function withCollectedInvoiceQueueDetailsSummary(array $job): array { $job['details_summary'] = economic_transfer_queue_details_summary::buildCollectedInvoiceSummary($job); + $collected_invoice_id = (int)($job['details_summary']['target']['collected_invoice_id'] ?? 0); + if ($collected_invoice_id > 0) { + $job['details_summary']['customer'] = $this->resolveCollectedInvoiceQueueCustomerSummary( + $job['details_summary']['customer'] ?? [], + $collected_invoice_id + ); + } return $job; } @@ -2035,6 +2319,72 @@ class orderInvoicesRoute }, $jobs)); } + private function resolveCollectedInvoiceQueueCustomerSummary(array $customer, int $collected_invoice_id): array + { + global $db; + + $customer_number = isset($customer['customer_number']) && is_numeric($customer['customer_number']) + ? (int)$customer['customer_number'] + : null; + $customer_name = is_string($customer['name'] ?? null) && trim((string)$customer['name']) !== '' + ? trim((string)$customer['name']) + : null; + + if ($customer_number !== null && $customer_name !== null) { + return [ + 'customer_number' => $customer_number, + 'name' => $customer_name, + ]; + } + + $collected_invoice_id = max(0, $collected_invoice_id); + if ($collected_invoice_id < 1) { + return [ + 'customer_number' => $customer_number, + 'name' => $customer_name, + ]; + } + + $sql = "SELECT coi.customer_number, coi.name AS invoice_name, u.display_name + FROM collected_order_invoices coi + LEFT JOIN users u ON u.customer_number = coi.customer_number + WHERE coi.id = $collected_invoice_id + LIMIT 1"; + $result = $db->query($sql); + if (!$result instanceof \mysqli_result) { + return [ + 'customer_number' => $customer_number, + 'name' => $customer_name, + ]; + } + + $row = $result->fetch_assoc(); + if (!is_array($row)) { + return [ + 'customer_number' => $customer_number, + 'name' => $customer_name, + ]; + } + + $resolved_customer_number = isset($row['customer_number']) && is_numeric($row['customer_number']) + ? (int)$row['customer_number'] + : $customer_number; + $display_name = trim((string)($row['display_name'] ?? '')); + $invoice_name = trim((string)($row['invoice_name'] ?? '')); + $resolved_name = $customer_name; + if ($resolved_name === null && $display_name !== '' && strtolower($display_name) !== 'unnamed') { + $resolved_name = $display_name; + } + if ($resolved_name === null && $invoice_name !== '') { + $resolved_name = $invoice_name; + } + + return [ + 'customer_number' => $resolved_customer_number, + 'name' => $resolved_name, + ]; + } + /** * @throws Exception */ diff --git a/services/nginx/app/routes/orderItemsRoute.php b/services/nginx/app/routes/orderItemsRoute.php index 3acf04b2..20d532ba 100644 --- a/services/nginx/app/routes/orderItemsRoute.php +++ b/services/nginx/app/routes/orderItemsRoute.php @@ -6,6 +6,7 @@ use classes\authentication; use objects\logs_o; use objects\order_items_o; use objects\orders_o; +use objects\products_o; use traits\route_t; class orderItemsRoute @@ -69,6 +70,13 @@ class orderItemsRoute $price = (int)self::getParameter('price'); } } + $product = (new products_o())->getProductById((int)$data['product_id']); + if (!$product->exists()) { + $response->error('Product not found', 404); + } + if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') { + $response->error('Notes is required for this product', 400); + } // Add the order item to the order This is done individually, to make the notes to the individual order items possible $order_items = (new order_items_o()); @@ -203,6 +211,14 @@ class orderItemsRoute if (!isset($data['quantity'])) { $response->error('Quantity is required', 400); } + $orderItem = (new order_items_o())->getOrderItemById((int)$data['id']); + if (!$orderItem->exists()) { + $response->error('Order item not found', 404); + } + $product = (new products_o())->getProductById((int)$orderItem->product_id->value()); + if ($product->requiresOrderItemNote() && trim((string)$data['notes']) === '') { + $response->error('Notes is required for this product', 400); + } // Update the order item (new order_items_o())->updateOrderItem((int)$data['id'], (int)$data['price'], (string)$data['notes'], (string)$data['reference'], (int)$data['quantity']); // Log the incident diff --git a/services/nginx/app/routes/ordersRoute.php b/services/nginx/app/routes/ordersRoute.php index cf02a459..5f86f74b 100644 --- a/services/nginx/app/routes/ordersRoute.php +++ b/services/nginx/app/routes/ordersRoute.php @@ -7,6 +7,7 @@ use classes\attachment_store; use classes\attachments; use classes\authentication; use classes\economic; +use classes\order_reference_suggestions_service; use classes\orders_input_normalizer; use classes\response; use classes\stripe; @@ -15,6 +16,7 @@ use objects\collected_order_invoices_o; use objects\departments_o; use objects\economic_module_orders; use objects\logs_o; +use objects\order_bookings_o; use objects\orders_o; use objects\stripe_module_orders_o; use objects\stripe_payment_intents_o; @@ -28,6 +30,52 @@ class ordersRoute public function run(): void { + $this->get('/orders/reference-suggestions', function () { + global $response; + $auth = new authentication(); + $user = $auth->get_user(); + if ($user === false) { + (new logs_o())->add('orders', 'global', 1, 0, 'LIST_ORDER_REFERENCE_SUGGESTIONS', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + + $this->requirePermission('list_orders'); + self::requireParameters(['department_id']); + $departmentId = (int)self::getParameter('department_id'); + self::requireParameterIntPositive($departmentId, 'department_id'); + self::requireDepartmentAccess((string)$departmentId); + + $search = trim((string)(self::getParameter('search') ?? '')); + if (strlen($search) > 255) { + $response->error('Parameter search must be at most 255 characters long', 400); + } + + foreach (['reg_1', 'reg_2', 'reg_3'] as $plateParameter) { + $plateValue = (string)(self::getParameter($plateParameter) ?? ''); + if (strlen($plateValue) > 32) { + $response->error('Parameter ' . $plateParameter . ' must be at most 32 characters long', 400); + } + } + + $suggestions = (new order_reference_suggestions_service())->suggest([ + 'search' => $search, + 'department_id' => $departmentId, + 'customer_id' => self::getParameter('customer_id') ?? null, + 'reg_1' => self::getParameter('reg_1') ?? '', + 'reg_2' => self::getParameter('reg_2') ?? '', + 'reg_3' => self::getParameter('reg_3') ?? '', + 'limit' => self::getParameter('limit') ?? null, + ]); + + (new logs_o())->add('orders', (string)$departmentId, 1, (int)$user->id, 'LIST_ORDER_REFERENCE_SUGGESTIONS', 'Successfully listed POS reference suggestions'); + $response->success($suggestions); + }, + [ + 'list_orders' => 'List POS order reference suggestions', + 'department_access_:id' => 'Access to the department used for reference suggestions', + ] + ); + $this->get('/orders', function () { // Require the user to be logged in global $response; @@ -146,18 +194,25 @@ class ordersRoute $response->error($e->getMessage(), 400); } + $bookingId = !empty($data['booking_id']) ? (int)$data['booking_id'] : null; + $po = $this->resolveOrderPoForBookingDefault( + array_key_exists('po', $data) ? $data['po'] : null, + array_key_exists('po', $data), + $bookingId + ); $new_data = [ 'customer_id' => (int)$data['customer_id'], 'department_id' => (int)$data['department_id'], 'reference' => (string)$data['reference'] ?? '', 'cashier_id' => (int)$user->id, // The user who created the order 'notes' => (string)$data['notes'] ?? '', + ...($po !== null ? ['po' => $po] : []), 'reg_1' => (string)$reg_1, 'reg_2' => (string)$reg_2, 'reg_3' => (string)$reg_3, ...(!empty($data['lane']) ? ['lane' => (int)$data['lane']] : []), // Optional lane ...(!empty($data['wash_id']) ? ['wash_id' => (string)$data['wash_id']] : []), // Optional wash ID - ...(!empty($data['booking_id']) ? ['booking_id' => (int)$data['booking_id']] : []), // Optional booking ID + ...($bookingId !== null ? ['booking_id' => $bookingId] : []), // Optional booking ID 'created_at' => $createdAt, // Default to current time if not set ...(array_key_exists('include_in_invoice', $data) ? ['include_in_invoice' => $includeInInvoice] : []), ...(array_key_exists('safety_seal', $data) ? ['safety_seal' => orders_o::normalizeSafetySealValue($data['safety_seal'])] : []), @@ -218,15 +273,48 @@ class ordersRoute // Get the current order $order = (new orders_o())->getOrderById((int)$id); // Check if the order exists - if (!$order->exists()) { + if (!isset($order->id) || (int)$order->id < 1 || !$order->exists()) { $response->error('Order not found', 400); } // Check if the user has access to the department self::requireDepartmentAccess((int)$order->department_id->value()); + $confirmed = filter_var($this->fromRequest('confirmed'), FILTER_VALIDATE_BOOLEAN) === true; + $deleteProtection = $order->getDeleteProtectionSummary(); + if ($deleteProtection['requires_confirmation'] && !$confirmed) { + (new logs_o())->add( + 'orders', + $order->department_id->value(), + 1, + $user->id, + 'DELETE_ORDER_CONFIRMATION_REQUIRED', + 'Order deletion requires confirmation (ID: ' . $id + . '; reasons: ' . implode(',', $deleteProtection['protected_reasons']) + . '; order_items: ' . $deleteProtection['order_item_count'] + . '; attachments: ' . $deleteProtection['attachment_count'] . ')' + ); + $response->error([ + 'message' => 'Order deletion requires confirmation', + ...$deleteProtection, + ], 409); + } // Delete the order $order->delete(); // Log the incident - (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DELETE_ORDER', 'Successfully deleted an order (ID: ' . $id . ')'); + if ($deleteProtection['requires_confirmation']) { + (new logs_o())->add( + 'orders', + $order->department_id->value(), + 1, + $user->id, + 'DELETE_ORDER_CONFIRMED', + 'Successfully deleted a protected order after confirmation (ID: ' . $id + . '; reasons: ' . implode(',', $deleteProtection['protected_reasons']) + . '; order_items: ' . $deleteProtection['order_item_count'] + . '; attachments: ' . $deleteProtection['attachment_count'] . ')' + ); + } else { + (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DELETE_ORDER', 'Successfully deleted an order (ID: ' . $id . ')'); + } // Return a success message $response->success(['message' => 'Order deleted successfully']); } else { @@ -1097,7 +1185,9 @@ class ordersRoute } // If the booking ID is set, validate it if (isset($data['booking_id'])) { - $order->booking_id->set((int)$data['booking_id']); + $bookingId = (int)$data['booking_id']; + $order->booking_id->set($bookingId); + $this->applyBookingPoDefaultToOrder($order, $bookingId); } // Check if the invoice collection is set if (isset($data['invoice_collection_id']) && !$shouldAutoReassignInvoiceCollection) { @@ -1145,6 +1235,55 @@ class ordersRoute } } + private function resolveOrderPoForBookingDefault(mixed $po, bool $poProvided, ?int $bookingId): ?string + { + $currentPo = is_scalar($po) || $po === null ? trim((string)$po) : ''; + if ($currentPo !== '') { + return $currentPo; + } + + $bookingPo = $this->getBookingPoDefault($bookingId); + if ($bookingPo !== null) { + return $bookingPo; + } + + return $poProvided ? '' : null; + } + + private function applyBookingPoDefaultToOrder(orders_o $order, ?int $bookingId = null): void + { + $currentPo = trim((string)($order->po->value() ?? '')); + if ($currentPo !== '') { + return; + } + + $bookingPo = $this->getBookingPoDefault($bookingId ?? (int)($order->booking_id->value() ?? 0)); + if ($bookingPo === null) { + return; + } + + $order->po->set($bookingPo); + } + + private function getBookingPoDefault(?int $bookingId): ?string + { + if ($bookingId === null || $bookingId <= 0) { + return null; + } + + try { + $booking = (new order_bookings_o())->select($bookingId); + if (!$booking->exists()) { + return null; + } + + $bookingPo = trim((string)($booking->po->value() ?? '')); + return $bookingPo !== '' ? $bookingPo : null; + } catch (\Throwable) { + return null; + } + } + private function normalizeLegacyEditableFieldPayload(array $data, response $response): array { if (!array_key_exists('field', $data) && !array_key_exists('value', $data)) { diff --git a/services/nginx/app/routes/pingRoute.php b/services/nginx/app/routes/pingRoute.php index 3ea9297f..4d48d45f 100644 --- a/services/nginx/app/routes/pingRoute.php +++ b/services/nginx/app/routes/pingRoute.php @@ -2,6 +2,7 @@ namespace routes; +use classes\release_manager; use traits\route_t; class pingRoute @@ -15,6 +16,8 @@ class pingRoute $response->success([ 'message' => 'pong', 'time' => date('c'), + 'backend_version' => release_manager::backendVersion(), + 'api_commit_sha' => release_manager::backendCommitSha(), ]); }); } diff --git a/services/nginx/app/routes/productsRoute.php b/services/nginx/app/routes/productsRoute.php index 65d2cc48..f715873e 100644 --- a/services/nginx/app/routes/productsRoute.php +++ b/services/nginx/app/routes/productsRoute.php @@ -152,7 +152,7 @@ class productsRoute 'piktogram' => (string)$product['piktogram'], 'economic_product_id' => (int)$product['economic_product_id'], 'apply_category_discount' => (boolean)$product['apply_category_discount'], - 'requires_note' => (boolean)$product['requires_note'], + 'requires_note' => \objects\products_o::productDataRequiresOrderItemNote($product), 'created_at' => (string)$product['created_at'], 'updated_at' => (string)$product['updated_at'], 'addons' => (new product_options_o())->getProductOptions($product['id']), @@ -432,4 +432,4 @@ class productsRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/releaseManagerRoute.php b/services/nginx/app/routes/releaseManagerRoute.php new file mode 100644 index 00000000..af6e3650 --- /dev/null +++ b/services/nginx/app/routes/releaseManagerRoute.php @@ -0,0 +1,533 @@ +get('/release/bootstrap', function () { + global $response; + $response->success((new release_manager())->bootstrap()); + }); + + $this->get('/release/runtime', function () { + global $response; + $response->success((new release_manager())->runtimeForCurrentPrincipal($this->getParametersAsArray())); + }); + + $this->post('/release/timeline/events', function () { + global $response; + $payload = $this->requestPayload(); + $events = is_array($payload['events'] ?? null) ? $payload['events'] : ($payload['event'] ?? $payload); + $context = is_array($payload['context'] ?? null) ? $payload['context'] : []; + $response->success((new release_manager())->ingestTimelineEvents( + is_array($events) ? $events : [], + $context + ), 202); + }); + + $this->post('/release/github/webhook', function () { + global $response; + try { + $headers = function_exists('getallheaders') ? getallheaders() : []; + $rawBody = file_get_contents('php://input') ?: ''; + $response->success((new release_manager())->handleGithubWebhook($headers, $rawBody), 202); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 401); + } + }); + + $this->post('/release/gate/test-runs', function () { + global $response; + $manager = new release_manager(); + if (!$manager->verifyReleaseGateToken($this->releaseGateToken())) { + $response->error(['message' => 'Invalid release gate token.'], 401); + return; + } + + try { + $response->success($manager->runReleaseTest($this->requestPayload(), null), 202); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }); + + $this->get('/superuser/releases', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + $response->success((new release_manager())->summary()); + }, [ + 'superuser_release_manager_view' => 'View release manager channels, deployments, and health', + ]); + + $this->get('/superuser/releases/config', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + $response->success((new release_manager())->releaseConfig()); + }, [ + 'superuser_release_manager_view' => 'View Release Manager source configuration', + ]); + + $this->get('/superuser/releases/operations', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + $response->success((new release_manager())->listOperations($this->getParametersAsArray())); + }, [ + 'superuser_release_manager_view' => 'View release operation runs', + ]); + + $this->get('/superuser/releases/operations/{id}', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + try { + $response->success((new release_manager())->operationDetail($this->routeId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 404); + } + }, [ + 'superuser_release_manager_view' => 'Inspect release operation diagnostics', + ]); + + $this->post('/superuser/releases/test-runs', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + $response->success((new release_manager())->runReleaseTest($this->requestPayload(), $this->actorUserId()), 202); + }, [ + 'superuser_release_manager_deploy' => 'Run Release Manager tests with operation diagnostics', + ]); + + $this->post('/superuser/releases/config', function () { + global $response; + $this->requirePermission('superuser_release_manager_manage'); + try { + $response->success((new release_manager())->updateReleaseConfig($this->requestPayload(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_manage' => 'Update Release Manager source configuration', + ]); + + $this->get('/superuser/releases/github/repositories', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + try { + $response->success((new release_manager())->listGithubRepositories($this->getParametersAsArray())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_view' => 'List private GitHub repositories available to Release Manager', + ]); + + $this->get('/superuser/releases/github/branches', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->listGithubBranches($this->getParametersAsArray())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_deploy' => 'List GitHub branches for a Release Manager repository', + ]); + + $this->get('/superuser/releases/github/commits', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->listGithubCommits($this->getParametersAsArray())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_deploy' => 'List or resolve GitHub commits for a Release Manager repository', + ]); + + $this->post('/superuser/releases/github/test', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + $response->success((new release_manager())->testGithubRepositoryAccess($this->requestPayload())); + }, [ + 'superuser_release_manager_deploy' => 'Test Release Manager access to a GitHub repository, branch, and commit', + ]); + + $this->get('/superuser/releases/channels', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + $response->success((new release_manager())->listChannels()); + }, [ + 'superuser_release_manager_view' => 'View release channels', + ]); + + $this->post('/superuser/releases/channels', function () { + global $response; + $this->requirePermission('superuser_release_manager_manage'); + try { + $response->success((new release_manager())->createChannel($this->requestPayload(), $this->actorUserId()), 201); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_manage' => 'Create release channels', + ]); + + $this->patch('/superuser/releases/channels/{id}', function () { + global $response; + $this->requirePermission('superuser_release_manager_manage'); + try { + $response->success((new release_manager())->updateChannel($this->routeId(), $this->requestPayload(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_manage' => 'Update release channels', + ]); + + $this->post('/superuser/releases/channels/{id}/rollback', function () { + global $response; + $this->requirePermission('superuser_release_manager_rollback'); + try { + $response->success((new release_manager())->rollbackChannel($this->routeId(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_release_manager_rollback' => 'Rollback an active release channel', + ]); + + $this->post('/superuser/releases/channels/{id}/sync', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->syncChannel($this->routeId(), $this->actorUserId()), 202); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_release_manager_deploy' => 'Sync the latest frontend and API branch commits into a release channel', + ]); + + $this->post('/superuser/releases/channels/{id}/bundle', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->setChannelBundle($this->routeId(), $this->requestPayload(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_release_manager_deploy' => 'Set the active release bundle for a channel', + ]); + + $this->get('/superuser/releases/assignments', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + $response->success((new release_manager())->listAssignments()); + }, [ + 'superuser_release_manager_view' => 'View release channel assignments', + ]); + + $this->get('/superuser/releases/assignment-subjects', function () { + global $response; + $this->requirePermission('superuser_release_manager_manage'); + $response->success((new release_manager())->searchAssignmentSubjects($this->getParametersAsArray())); + }, [ + 'superuser_release_manager_manage' => 'Search users, subusers, and customers for Release Manager assignments', + ]); + + $this->post('/superuser/releases/assignments', function () { + global $response; + $this->requirePermission('superuser_release_manager_manage'); + try { + $response->success((new release_manager())->createAssignment($this->requestPayload(), $this->actorUserId()), 201); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_manage' => 'Assign users, subusers, or customers to release channels', + ]); + + $this->delete('/superuser/releases/assignments/{id}', function () { + global $response; + $this->requirePermission('superuser_release_manager_manage'); + try { + $response->success((new release_manager())->deleteAssignment($this->routeId(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 404); + } + }, [ + 'superuser_release_manager_manage' => 'Remove release channel assignments', + ]); + + $this->get('/superuser/releases/targets', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + $response->success((new release_manager())->listDeploymentTargets()); + }, [ + 'superuser_release_manager_deploy' => 'View release deployment targets', + ]); + + $this->post('/superuser/releases/targets', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->upsertDeploymentTarget($this->requestPayload(), $this->actorUserId()), 201); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_deploy' => 'Create or update GitHub to Coolify release deployment targets', + ]); + + $this->delete('/superuser/releases/targets/{id}', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->deleteDeploymentTarget($this->routeId(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 404); + } + }, [ + 'superuser_release_manager_deploy' => 'Delete release deployment targets', + ]); + + $this->get('/superuser/releases/service-sets', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + $response->success((new release_manager())->listServiceSets()); + }, [ + 'superuser_release_manager_view' => 'View reusable Release Manager service sets', + ]); + + $this->post('/superuser/releases/service-sets', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->createServiceSet($this->requestPayload(), $this->actorUserId()), 201); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_deploy' => 'Create reusable Release Manager service sets', + ]); + + $this->delete('/superuser/releases/service-sets/{id}', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success( + (new release_manager())->deleteServiceSet( + $this->routeId(), + $this->requestPayload(), + $this->actorUserId() + ) + ); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_deploy' => 'Remove inactive isolated Release Manager service sets', + ]); + + $this->post('/superuser/releases/service-sets/{id}/isolated-data-services', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success( + (new release_manager())->completeIsolatedStackDataServices( + $this->routeId(), + $this->requestPayload(), + $this->actorUserId() + ), + 202 + ); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_deploy' => 'Create missing isolated stack database, Redis, and MinIO services', + ]); + + $this->get('/superuser/releases/bundles', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + $limit = (int)($this->getParameter('limit') ?? 50); + $response->success((new release_manager())->listBundles($limit)); + }, [ + 'superuser_release_manager_view' => 'View Release Manager bundles', + ]); + + $this->post('/superuser/releases/bundles', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->createBundle($this->requestPayload(), $this->actorUserId()), 201); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_deploy' => 'Create Release Manager full-stack bundles', + ]); + + $this->post('/superuser/releases/bundles/{id}/deploy', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->deployBundle($this->routeId(), $this->actorUserId()), 202); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_release_manager_deploy' => 'Deploy Release Manager full-stack bundles', + ]); + + $this->post('/superuser/releases/bundles/{id}/promote', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->promoteBundle($this->routeId(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_release_manager_deploy' => 'Promote Release Manager bundles without data failover', + ]); + + $this->get('/superuser/releases/deployments', function () { + global $response; + $this->requirePermission('superuser_release_manager_view'); + $limit = (int)($this->getParameter('limit') ?? 50); + $response->success((new release_manager())->listDeployments($limit)); + }, [ + 'superuser_release_manager_view' => 'View release deployments', + ]); + + $this->post('/superuser/releases/deployments', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->startDeployment($this->requestPayload(), $this->actorUserId()), 202); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_release_manager_deploy' => 'Trigger release deployments from GitHub/Coolify targets', + ]); + + $this->post('/superuser/releases/deployments/{id}/promote', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + try { + $response->success((new release_manager())->promoteDeployment($this->routeId(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_release_manager_deploy' => 'Promote a deployment to its release channel', + ]); + + $this->post('/superuser/releases/issues/actions', function () { + global $response; + $this->requirePermission('superuser_release_manager_deploy'); + $response->success((new release_manager())->runIssueAction($this->requestPayload(), $this->actorUserId())); + }, [ + 'superuser_release_manager_deploy' => 'Run a safe Release Manager issue resolution action', + ]); + + $this->post('/superuser/releases/replay-targets', function () { + global $response; + $this->requirePermission('superuser_release_manager_replay'); + try { + $response->success((new release_manager())->setReplayTarget($this->requestPayload(), $this->actorUserId()), 201); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_release_manager_replay' => 'Enable release timeline replay capture for a user, customer, subuser, or channel', + ]); + + $this->get('/superuser/releases/timeline/sessions', function () { + global $response; + $this->requirePermission('superuser_release_manager_replay'); + $response->success((new release_manager())->listTimelineSessions($this->getParametersAsArray())); + }, [ + 'superuser_release_manager_replay' => 'List release timeline replay sessions', + ]); + + $this->get('/superuser/releases/timeline/sessions/{traceId}', function () { + global $response; + $this->requirePermission('superuser_release_manager_replay'); + try { + $response->success((new release_manager())->timelineSessionDetail((string)$this->fromRoute('traceId'))); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 404); + } + }, [ + 'superuser_release_manager_replay' => 'Inspect one release timeline replay session', + ]); + + $this->get('/superuser/releases/timeline', function () { + global $response; + $this->requirePermission('superuser_release_manager_replay'); + $response->success((new release_manager())->searchTimeline($this->getParametersAsArray())); + }, [ + 'superuser_release_manager_replay' => 'Replay release failure timelines', + ]); + } + + 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; + } + + private function releaseGateToken(): string + { + $headers = function_exists('getallheaders') ? getallheaders() : []; + $authorization = (string)($headers['Authorization'] ?? $headers['authorization'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? ''); + if (preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches) === 1) { + return trim($matches[1]); + } + + return trim((string)( + $headers['X-Release-Gate-Token'] + ?? $headers['x-release-gate-token'] + ?? $_SERVER['HTTP_X_RELEASE_GATE_TOKEN'] + ?? '' + )); + } +} diff --git a/services/nginx/app/routes/subusersRoute.php b/services/nginx/app/routes/subusersRoute.php index 2023e6a2..b0b36e6e 100644 --- a/services/nginx/app/routes/subusersRoute.php +++ b/services/nginx/app/routes/subusersRoute.php @@ -44,6 +44,14 @@ class subusersRoute } } + private function requireSubuserPasswordPolicy(string $password): void + { + self::requireType($password, self::type_string()); + self::requireMinLength('password', subusers_o::PASSWORD_MIN_LENGTH); + self::requireMaxLength('password', subusers_o::PASSWORD_MAX_LENGTH); + self::requireRegex($password, subusers_o::PASSWORD_PATTERN, subusers_o::PASSWORD_COMPLEXITY_MESSAGE); + } + private function requireManagedCustomerScope(subusers_permission_node_key $node, ?int $targetCustomerNumber = null): int { global $response; @@ -121,6 +129,36 @@ class subusersRoute return $normalized === '' ? null : $normalized; } + private function resolveCustomerNames(array $customerNumbers): array + { + $customerNumbers = array_values(array_unique(array_filter( + array_map('intval', $customerNumbers), + static fn (int $customerNumber): bool => $customerNumber > 0 + ))); + + if ($customerNumbers === []) { + return []; + } + + return (new users_o())->getCustomerNames($customerNumbers, false); + } + + private function resolveCustomerName(int $customerNumber, array $customerNames = []): ?string + { + if ($customerNumber <= 0) { + return null; + } + + $key = (string)$customerNumber; + $name = $customerNames[$key] ?? null; + if (!is_string($name)) { + $name = $this->resolveCustomerNames([$customerNumber])[$key] ?? null; + } + + $name = trim((string)$name); + return $name === '' || $name === 'Unknown Customer' ? null : $name; + } + private function assertSubuserIdentifiersAvailable( ?int $phoneCountryCode, ?int $phone, @@ -244,10 +282,10 @@ class subusersRoute ]; } - private function buildSubuserManagementPayload(subusers_o $subuser, int $customerNumber): array + private function buildSubuserManagementPayload(subusers_o $subuser, int $customerNumber, ?string $customerName = null): array { $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true); - $grantPermissions = $grant ? $this->parsePermissionsPayload($grant->permissions->value(), []) : []; + $grantPermissions = $grant ? subuser_grants_o::normalizePermissionsValue($grant->permissions->value()) : []; $setupRequired = $subuser->requiresSetup(); $grantEnabled = $grant ? (bool)$grant->enabled->value() : false; $inviteAccepted = !$setupRequired; @@ -274,6 +312,8 @@ class subusersRoute 'invite_accepted' => $inviteAccepted, 'can_resend_invite' => $setupRequired, 'profile_editable_by_manager' => false, + 'customer_number' => $customerNumber, + 'customer_name' => $customerName ?? $this->resolveCustomerName($customerNumber), 'grant_id' => $grant ? (int)$grant->id : null, 'grant_enabled' => $grantEnabled, 'grant_note' => $grant ? $grant->note->value() : null, @@ -290,6 +330,10 @@ class subusersRoute 'enabled' => 1, 'deleted_at' => null, ], ['permissions', 'billing_customer_number']); + $customerNames = $this->resolveCustomerNames(array_map( + static fn (array $grant): int => (int)($grant['billing_customer_number'] ?? 0), + $grants + )); return [ 'id' => (int)$subuser->id, @@ -298,11 +342,12 @@ class subusersRoute 'email' => $subuser->email->value(), 'phone_country_code' => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null, 'phone' => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null, - 'grants' => array_map(function ($grant) { + 'grants' => array_map(function ($grant) use ($customerNames) { + $customerNumber = (int)$grant['billing_customer_number']; return [ - 'name' => (new users_o())->getCustomerName((int)$grant['billing_customer_number']), - 'billing_customer_number' => (int)$grant['billing_customer_number'], - 'permissions' => $this->parsePermissionsPayload($grant['permissions'] ?? null, []), + 'name' => $this->resolveCustomerName($customerNumber, $customerNames), + 'billing_customer_number' => $customerNumber, + 'permissions' => subuser_grants_o::normalizePermissionsValue($grant['permissions'] ?? null), ]; }, $grants), 'created_at' => $subuser->created_at->value() ?? null, @@ -312,6 +357,297 @@ class subusersRoute ]; } + private function parseSuperuserPaginationRequest(): array + { + global $response; + + $page = max(1, (int)($response->getRequestParameter('page') ?: 1)); + $limitRaw = $response->getRequestParameter('limit'); + $limit = is_string($limitRaw) && strtolower($limitRaw) === 'all' + ? 1000 + : (int)($limitRaw ?: 100); + $limit = max(1, min($limit, 1000)); + + $search = $this->normalizeOptionalString($response->getRequestParameter('search')); + $orderRaw = (string)($response->getRequestParameter('order') ?: 'created_at:DESC'); + $orderParts = explode(':', $orderRaw, 2); + $orderField = $orderParts[0] ?? 'created_at'; + $orderDirection = strtoupper($orderParts[1] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; + $allowedOrderFields = [ + 'id' => 's.`id`', + 'created_at' => 's.`created_at`', + 'updated_at' => 'row_updated_at', + 'customer_number' => 'g.`billing_customer_number`', + 'grant_id' => 'g.`id`', + 'name' => 's.`name`', + ]; + + if (!isset($allowedOrderFields[$orderField])) { + $orderField = 'created_at'; + } + + return [ + 'page' => $page, + 'limit' => $limit, + 'search' => $search, + 'order_field' => $orderField, + 'order_sql' => $allowedOrderFields[$orderField], + 'order_direction' => $orderDirection, + ]; + } + + private function bindStatementParameters(\mysqli_stmt $statement, string $types, array $params): void + { + if ($params === []) { + return; + } + + $refs = []; + foreach ($params as $key => $value) { + $refs[$key] = &$params[$key]; + } + + $statement->bind_param($types, ...$refs); + } + + private function buildSuperuserSubuserManagementPayload(array $row): array + { + $grantPermissions = subuser_grants_o::normalizePermissionsValue($row['grant_permissions'] ?? null); + $setupRequired = !is_string($row['password'] ?? null) || trim((string)$row['password']) === ''; + $grantEnabled = (bool)((int)($row['grant_enabled'] ?? 0)); + + $accessState = 'inactive'; + if (!empty($row['grant_id']) && $grantEnabled) { + $accessState = $setupRequired ? 'pending_setup' : 'active'; + } elseif (!empty($row['grant_id'])) { + $accessState = 'disabled'; + } + + return [ + 'id' => (int)$row['id'], + 'username' => $row['username'] ?? null, + 'name' => $row['name'] ?? null, + 'email' => $row['email'] ?? null, + 'phone_country_code' => $row['phone_country_code'] !== null ? (int)$row['phone_country_code'] : null, + 'phone' => $row['phone'] !== null ? (int)$row['phone'] : null, + 'created_at' => $row['created_at'] ?? null, + 'updated_at' => $row['row_updated_at'] ?? $row['updated_at'] ?? null, + 'suspended_at' => $row['suspended_at'] ?? null, + 'two_factor_enabled' => (bool)((int)($row['two_factor_enabled'] ?? 0)), + 'setup_required' => $setupRequired, + 'invite_accepted' => !$setupRequired, + 'can_resend_invite' => $setupRequired, + 'profile_editable_by_manager' => false, + 'customer_number' => (int)$row['customer_number'], + 'customer_name' => $row['customer_name'] ?: null, + 'grant_id' => (int)$row['grant_id'], + 'grant_enabled' => $grantEnabled, + 'grant_note' => $row['grant_note'] ?? null, + 'grant_permissions' => $grantPermissions, + 'permissions' => $grantPermissions, + 'grant_created_at' => $row['grant_created_at'] ?? null, + 'grant_updated_at' => $row['grant_updated_at'] ?? null, + 'access_state' => $accessState, + ]; + } + + private function listSuperuserSubusers(): array + { + global $db, $response; + + $pagination = $this->parseSuperuserPaginationRequest(); + $offset = ((int)$pagination['page'] - 1) * (int)$pagination['limit']; + $where = ['g.`deleted_at` IS NULL']; + $params = []; + $types = ''; + + $includeNonEnabled = true; + if (self::isParametersSet(['include_non_enabled'])) { + $tmp = filter_var(self::getParameter('include_non_enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + $includeNonEnabled = $tmp === null ? true : (bool)$tmp; + } + if (!$includeNonEnabled) { + $where[] = 'g.`enabled` = 1'; + } + + if ($pagination['search'] !== null) { + $where[] = "( + CAST(s.`id` AS CHAR) LIKE ? + OR s.`username` LIKE ? + OR s.`name` LIKE ? + OR s.`email` LIKE ? + OR CAST(s.`phone_country_code` AS CHAR) LIKE ? + OR CAST(s.`phone` AS CHAR) LIKE ? + OR CAST(g.`billing_customer_number` AS CHAR) LIKE ? + OR g.`note` LIKE ? + OR u.`display_name` LIKE ? + )"; + $search = '%' . $pagination['search'] . '%'; + for ($i = 0; $i < 9; $i++) { + $params[] = $search; + $types .= 's'; + } + } + + $whereSql = 'WHERE ' . implode(' AND ', $where); + $fromSql = " + FROM `subuser_grants` g + INNER JOIN `subusers` s ON s.`id` = g.`subuser` + LEFT JOIN `users` u ON u.`customer_number` = g.`billing_customer_number` + "; + + $countSql = "SELECT COUNT(*) AS `count` $fromSql $whereSql"; + $countStatement = $db->conn->prepare($countSql); + if ($countStatement === false) { + throw new Exception('Failed to prepare subuser count query: ' . $db->conn->error); + } + $this->bindStatementParameters($countStatement, $types, $params); + $countStatement->execute(); + $countResult = $countStatement->get_result(); + $total = (int)($countResult->fetch_assoc()['count'] ?? 0); + $countStatement->close(); + + $dataSql = " + SELECT + s.`id`, + s.`username`, + s.`password`, + s.`name`, + s.`email`, + s.`phone_country_code`, + s.`phone`, + s.`two_factor_enabled`, + s.`created_at`, + s.`updated_at`, + s.`suspended_at`, + g.`id` AS `grant_id`, + g.`billing_customer_number` AS `customer_number`, + g.`enabled` AS `grant_enabled`, + g.`note` AS `grant_note`, + g.`permissions` AS `grant_permissions`, + g.`created_at` AS `grant_created_at`, + g.`updated_at` AS `grant_updated_at`, + COALESCE(g.`updated_at`, s.`updated_at`) AS `row_updated_at`, + u.`display_name` AS `customer_name` + $fromSql + $whereSql + ORDER BY {$pagination['order_sql']} {$pagination['order_direction']} + LIMIT ? OFFSET ? + "; + + $dataStatement = $db->conn->prepare($dataSql); + if ($dataStatement === false) { + throw new Exception('Failed to prepare subuser list query: ' . $db->conn->error); + } + $dataParams = [...$params, (int)$pagination['limit'], $offset]; + $this->bindStatementParameters($dataStatement, $types . 'ii', $dataParams); + $dataStatement->execute(); + $result = $dataStatement->get_result(); + $rows = $result->fetch_all(MYSQLI_ASSOC); + $dataStatement->close(); + + $response->paginate( + (int)$pagination['page'], + (int)$pagination['limit'], + $total, + $pagination['search'], + null, + [$pagination['order_field'] => $pagination['order_direction']] + ); + + return array_map(fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row), $rows); + } + + private function handleInviteSubuserForCustomer(int $customerNumber): void + { + global $response; + + if ($customerNumber <= 0) { + $response->error('Customer number is required', 400); + } + + self::requireParameters(['name', 'phone_country_code', 'phone']); + + $name = $this->normalizeOptionalString(self::getParameter('name')); + $phoneCountryCode = (int)self::getParameter('phone_country_code'); + $phone = (int)self::getParameter('phone'); + $note = self::isParametersSet(['note']) ? $this->normalizeOptionalString(self::getParameter('note')) : null; + $enabled = true; + if (self::isParametersSet(['enabled'])) { + $tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + $enabled = $tmp === null ? true : (bool)$tmp; + } + $permissions = self::isParametersSet(['permissions']) + ? $this->parsePermissionsPayload(self::getParameter('permissions'), []) + : null; + + if ($name === null || strlen($name) < 3 || strlen($name) > 255) { + $response->error('Name must be between 3 and 255 characters long', 400); + } + self::requireType($phoneCountryCode, self::type_int()); + self::requireType($phone, self::type_int()); + self::requireMinLength('phone_country_code', 1); + self::requireMaxLength('phone_country_code', 3); + self::requireMinLength('phone', 4); + self::requireMaxLength('phone', 15); + if ($note !== null && strlen($note) > 65535) { + $response->error('Note must be at most 65535 characters long', 400); + } + + $subuser = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone); + if ($subuser === null) { + try { + $subuser = (new subusers_o())->add( + null, + null, + $name, + null, + $phoneCountryCode, + $phone + ); + } catch (Exception $exception) { + $response->error($exception->getMessage(), 400); + } + } + + $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true); + if ($grant === null) { + try { + $grant = (new subuser_grants_o())->add( + $customerNumber, + (int)$subuser->id, + $enabled, + $note, + $permissions ?? subuser_grants_o::defaultPermissions + ); + } catch (Exception $exception) { + $response->error('Failed to create subuser grant', 500); + } + } else { + $grantUpdates = ['enabled' => $enabled]; + if (self::isParametersSet(['note'])) { + $grantUpdates['note'] = $note; + } + if ($permissions !== null) { + $grantUpdates['permissions'] = $permissions; + } + try { + $grant->update($grantUpdates); + } catch (Exception $exception) { + $response->error('Failed to update subuser grant', 500); + } + $grant = (new subuser_grants_o())->select((int)$grant->id); + $grant->getObjectProperties(); + } + + $invite = $this->issueSetupInvite($subuser); + $response->success([ + 'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber), + 'grant' => $grant->asArray(), + 'invite' => $invite, + ]); + } + public function run(): void { // ============================= @@ -387,7 +723,7 @@ class subusersRoute 'name' => $subuser->name->value(), 'enabled' => $o->enabled, 'note' => $o->note, - 'permissions' => json_decode($o->permissions, true) ?: [], + 'permissions' => subuser_grants_o::normalizePermissionsValue($o->permissions ?? null), 'created_at' => $o->created_at, 'updated_at' => $o->updated_at, ]; @@ -649,10 +985,7 @@ class subusersRoute $token = (string)self::getParameter('token'); $password = (string)self::getParameter('password'); $name = (string)self::getParameter('name'); - self::requireType($password, self::type_string()); - self::requireMinLength('password', 8); - self::requireMaxLength('password', 255); - self::requireRegex($password, '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/', 'Password must contain at least one uppercase letter, one lowercase letter, and one number'); + $this->requireSubuserPasswordPolicy($password); self::requireType($name, self::type_string()); self::requireMinLength('name', 3); self::requireMaxLength('name', 255); @@ -747,9 +1080,7 @@ class subusersRoute } self::requireParameters(['password']); $password = (string)self::getParameter('password'); - self::requireType($password, self::type_string()); - self::requireMinLength('password', 8); - self::requireMaxLength('password', 255); + $this->requireSubuserPasswordPolicy($password); try { if (password_verify($password, $subuser->password->value())) { if ($subuser->isTwoFactorEnabled()) { @@ -770,9 +1101,18 @@ class subusersRoute // ============================= // Subusers - List & Get (with grant visibility) // ============================= + $this->get('/superuser/subusers', function () { + global $response; + $this->requirePermission('list_subusers'); + $response->success($this->listSuperuserSubusers()); + }, [ + 'list_subusers' => 'List all chauffeur access grants for superusers.', + ]); + $this->get('/subusers', function () { global $response; $customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST); + $customerName = $this->resolveCustomerName($customerNumber); $includeNonEnabled = false; if (self::isParametersSet(['include_non_enabled'])) { $tmp = filter_var(self::getParameter('include_non_enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); @@ -786,13 +1126,13 @@ class subusersRoute ); $objects = (new subusers_o()) - ->listObjectsWithPaginationIfSet(function ($o) use ($customerNumber) { + ->listObjectsWithPaginationIfSet(function ($o) use ($customerNumber, $customerName) { $subuser = (new subusers_o())->select((int)$o['id']); if (!$subuser->exists()) { return null; } $subuser->getObjectProperties(); - return $this->buildSubuserManagementPayload($subuser, $customerNumber); + return $this->buildSubuserManagementPayload($subuser, $customerNumber, $customerName); }, null, [], $existsClause); if (is_array($objects)) { @@ -873,82 +1213,46 @@ class subusersRoute $response->success($this->buildCurrentSubuserPayload($subuser)); }, []); + $this->post('/superuser/subusers/invite', function () { + self::requireParameters(['customer_number']); + $this->requirePermission('add_subusers'); + $customerNumber = (int)self::getParameter('customer_number'); + self::requireType($customerNumber, self::type_int()); + $this->handleInviteSubuserForCustomer($customerNumber); + }, [ + 'add_subusers' => 'Invite or link chauffeurs for any customer (superuser).', + ]); + $this->post('/subusers/invite', function () { + $customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD); + $this->handleInviteSubuserForCustomer($customerNumber); + }, [ + 'add_own_subusers' => 'Invite or link chauffeurs for own customer. Subusers require node: SUBUSERS_ADD and X-Customer-Number header.', + ]); + + $this->post('/superuser/subusers/invite/resend', function () { global $response; - $customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD); - self::requireParameters(['name', 'phone_country_code', 'phone']); + $this->requirePermission('edit_subusers'); + self::requireParameters(['id', 'customer_number']); + $subuserId = (int)self::getParameter('id'); + self::requireType($subuserId, self::type_int()); + $customerNumber = (int)self::getParameter('customer_number'); + self::requireType($customerNumber, self::type_int()); - $name = $this->normalizeOptionalString(self::getParameter('name')); - $phoneCountryCode = (int)self::getParameter('phone_country_code'); - $phone = (int)self::getParameter('phone'); - $note = self::isParametersSet(['note']) ? $this->normalizeOptionalString(self::getParameter('note')) : null; - $enabled = true; - if (self::isParametersSet(['enabled'])) { - $tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); - $enabled = $tmp === null ? true : (bool)$tmp; + $subuser = (new subusers_o())->select($subuserId); + if (!$subuser->exists()) { + $response->error('Subuser not found', 404); } - $permissions = self::isParametersSet(['permissions']) - ? $this->parsePermissionsPayload(self::getParameter('permissions'), []) - : null; + $subuser->getObjectProperties(); - if ($name === null || strlen($name) < 3 || strlen($name) > 255) { - $response->error('Name must be between 3 and 255 characters long', 400); - } - self::requireType($phoneCountryCode, self::type_int()); - self::requireType($phone, self::type_int()); - self::requireMinLength('phone_country_code', 1); - self::requireMaxLength('phone_country_code', 3); - self::requireMinLength('phone', 4); - self::requireMaxLength('phone', 15); - if ($note !== null && strlen($note) > 65535) { - $response->error('Note must be at most 65535 characters long', 400); - } - - $subuser = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone); - if ($subuser === null) { - try { - $subuser = (new subusers_o())->add( - null, - null, - $name, - null, - $phoneCountryCode, - $phone - ); - } catch (Exception $exception) { - $response->error($exception->getMessage(), 400); - } - } - - $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true); + $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer($subuserId, $customerNumber, true); if ($grant === null) { - try { - $grant = (new subuser_grants_o())->add( - $customerNumber, - (int)$subuser->id, - $enabled, - $note, - $permissions ?? subuser_grants_o::defaultPermissions - ); - } catch (Exception $exception) { - $response->error('Failed to create subuser grant', 500); - } - } else { - $grantUpdates = ['enabled' => $enabled]; - if (self::isParametersSet(['note'])) { - $grantUpdates['note'] = $note; - } - if ($permissions !== null) { - $grantUpdates['permissions'] = $permissions; - } - try { - $grant->update($grantUpdates); - } catch (Exception $exception) { - $response->error('Failed to update subuser grant', 500); - } - $grant = (new subuser_grants_o())->select((int)$grant->id); - $grant->getObjectProperties(); + $response->error('Subuser grant not found for selected customer', 404); + } + + if (!$subuser->requiresSetup()) { + $response->error('Driver account already accepted the invitation.', 409); } $invite = $this->issueSetupInvite($subuser); @@ -958,7 +1262,7 @@ class subusersRoute 'invite' => $invite, ]); }, [ - 'add_own_subusers' => 'Invite or link chauffeurs for own customer. Subusers require node: SUBUSERS_ADD and X-Customer-Number header.', + 'edit_subusers' => 'Resend chauffeur invites for a selected customer (superuser).', ]); $this->post('/subusers/invite/resend', function () { diff --git a/services/nginx/app/routes/superuserCoolifyRoute.php b/services/nginx/app/routes/superuserCoolifyRoute.php new file mode 100644 index 00000000..7cab8d70 --- /dev/null +++ b/services/nginx/app/routes/superuserCoolifyRoute.php @@ -0,0 +1,303 @@ +get('/superuser/coolify', function () { + global $response; + + $this->requirePermission('superuser_coolify_view'); + $response->success((new coolify_manager())->summary()); + }, [ + 'superuser_coolify_view' => 'View Coolify-managed replicated infrastructure targets', + ]); + + $this->get('/superuser/coolify/load-balancer', function () { + global $response; + + $this->requirePermission('superuser_coolify_view'); + $response->success((new coolify_manager())->loadBalancerSummary()); + }, [ + 'superuser_coolify_view' => 'View the Coolify public gateway Load Balancer state', + ]); + + $this->post('/superuser/coolify/load-balancer/reconcile', function () { + global $response; + + $this->requirePermission('superuser_coolify_manage'); + try { + $parameters = $this->getParametersAsArray(); + $dryRun = array_key_exists('dry_run', $parameters) + ? filter_var($parameters['dry_run'], FILTER_VALIDATE_BOOLEAN) + : !filter_var($parameters['enforce'] ?? false, FILTER_VALIDATE_BOOLEAN); + $result = (new coolify_manager())->reconcileLoadBalancer($dryRun, $this->actorUserId()); + if (($result['ok'] ?? false) !== true) { + $response->error($result, 409); + } + $response->success($result); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_manage' => 'Reconcile Hetzner Load Balancer targets and services for the Coolify gateway', + ]); + + $this->post('/superuser/coolify/load-balancer/routes/deploy', function () { + global $response; + + $this->requirePermission('superuser_coolify_manage'); + try { + $parameters = $this->getParametersAsArray(); + $dryRun = array_key_exists('dry_run', $parameters) + ? filter_var($parameters['dry_run'], FILTER_VALIDATE_BOOLEAN) + : !filter_var($parameters['enforce'] ?? false, FILTER_VALIDATE_BOOLEAN); + $result = (new coolify_manager())->deployGatewayApplicationRoutes($dryRun, $this->actorUserId()); + if (($result['ok'] ?? false) !== true) { + $response->error($result, 409); + } + $response->success($result); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_manage' => 'Deploy the Coolify API application route for the public gateway host', + ]); + + $this->post('/superuser/coolify/load-balancer/api/deploy', function () { + global $response; + + $this->requirePermission('superuser_coolify_manage'); + try { + $parameters = $this->getParametersAsArray(); + $dryRun = array_key_exists('dry_run', $parameters) + ? filter_var($parameters['dry_run'], FILTER_VALIDATE_BOOLEAN) + : !filter_var($parameters['enforce'] ?? false, FILTER_VALIDATE_BOOLEAN); + $deployRoutes = array_key_exists('deploy_routes', $parameters) + ? filter_var($parameters['deploy_routes'], FILTER_VALIDATE_BOOLEAN) + : true; + $result = (new coolify_manager())->deployGatewayApiCode($dryRun, $deployRoutes, $this->actorUserId()); + if (($result['ok'] ?? false) !== true) { + $response->error($result, 409); + } + $response->success($result); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_manage' => 'Deploy the latest Coolify API code for the public gateway host', + ]); + + $this->get('/superuser/coolify/gateways', function () { + global $response; + + $this->requirePermission('superuser_coolify_view'); + $response->success((new coolify_manager())->listLoadBalancerGateways()); + }, [ + 'superuser_coolify_view' => 'List Coolify public gateway Load Balancer targets', + ]); + + $this->post('/superuser/coolify/gateways', function () { + global $response; + + $this->requirePermission('superuser_coolify_manage'); + try { + $response->success((new coolify_manager())->saveLoadBalancerGateway( + $this->getParametersAsArray(), + $this->actorUserId() + ), 201); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_coolify_manage' => 'Create or update Coolify public gateway Load Balancer targets', + ]); + + $this->post('/superuser/coolify/gateways/{id}/test', function () { + global $response; + + $this->requirePermission('superuser_coolify_manage'); + try { + $response->success((new coolify_manager())->testLoadBalancerGateway( + $this->routeId(), + $this->actorUserId() + )); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_manage' => 'Probe an individual Coolify public gateway target', + ]); + + $this->post('/superuser/coolify/instances', function () { + global $response; + + $this->requirePermission('superuser_coolify_manage'); + try { + $response->success((new coolify_manager())->createInstance( + $this->getParametersAsArray(), + $this->actorUserId() + ), 201); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_coolify_manage' => 'Create and update Coolify API connections', + ]); + + $this->post('/superuser/coolify/instances/{id}/test', function () { + global $response; + + $this->requirePermission('superuser_coolify_manage'); + $response->success((new coolify_manager())->testInstance($this->routeId(), $this->actorUserId())); + }, [ + 'superuser_coolify_manage' => 'Test Coolify API connectivity', + ]); + + $this->get('/superuser/coolify/instances/{id}/placement', function () { + global $response; + + $this->requirePermission('superuser_coolify_view'); + try { + $response->success((new coolify_manager())->discoverInstancePlacement($this->routeId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 502); + } + }, [ + 'superuser_coolify_view' => 'Discover Coolify projects, environments, and servers for target placement', + ]); + + $this->get('/superuser/coolify/targets', function () { + global $response; + + $this->requirePermission('superuser_coolify_view'); + $kind = (string)($this->getParameter('kind') ?? ''); + $response->success((new coolify_manager())->listTargets($kind !== '' ? $kind : null)); + }, [ + 'superuser_coolify_view' => 'List Coolify-managed replication targets', + ]); + + $this->post('/superuser/coolify/targets', function () { + global $response; + + $this->requirePermission('superuser_coolify_manage'); + try { + $response->success((new coolify_manager())->createTarget( + $this->getParametersAsArray(), + $this->actorUserId() + ), 201); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_manage' => 'Create Coolify-managed MariaDB, Redis, and MinIO replication targets', + ]); + + $this->post('/superuser/coolify/targets/{id}/reconcile', function () { + global $response; + + $this->requirePermission('superuser_coolify_reconcile'); + try { + $result = (new coolify_manager())->reconcileTarget($this->routeId(), $this->actorUserId()); + if (($result['ok'] ?? false) !== true) { + $response->error($result, 409); + } + $response->success($result); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_reconcile' => 'Reconcile expected Coolify deployment state without primary downtime', + ]); + + $this->post('/superuser/coolify/targets/{id}/deploy', function () { + global $response; + + $this->requirePermission('superuser_coolify_reconcile'); + try { + $result = (new coolify_manager())->deployTarget($this->routeId(), $this->actorUserId()); + if (($result['ok'] ?? false) !== true) { + $response->error($result, 409); + } + $response->success($result); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_reconcile' => 'Deploy and provision a passive Coolify-managed replication target', + ]); + + $this->post('/superuser/coolify/targets/{id}/restart', function () { + global $response; + + $this->requirePermission('superuser_coolify_reconcile'); + try { + $result = (new coolify_manager())->restartTarget($this->routeId(), $this->actorUserId()); + if (($result['ok'] ?? false) !== true) { + $response->error($result, 409); + } + $response->success($result); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_reconcile' => 'Restart a passive Coolify target without restarting the active primary', + ]); + + $this->post('/superuser/coolify/targets/{id}/failover', function () { + global $response; + + $this->requirePermission('superuser_coolify_failover'); + try { + $response->success((new coolify_manager())->failoverTarget($this->routeId(), $this->actorUserId())); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_failover' => 'Promote a healthy Coolify-managed replica through the replication module', + ]); + + $this->delete('/superuser/coolify/targets/{id}', function () { + global $response; + + $this->requirePermission('superuser_coolify_manage'); + try { + $response->success((new coolify_manager())->deleteTarget( + $this->routeId(), + $this->getParametersAsArray(), + $this->actorUserId() + )); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_coolify_manage' => 'Delete Coolify target mappings with explicit destructive confirmation', + ]); + } + + 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; + } + } +} diff --git a/services/nginx/app/routes/superuserDepartmentRoute.php b/services/nginx/app/routes/superuserDepartmentRoute.php index 675fedfb..ff76ddc9 100644 --- a/services/nginx/app/routes/superuserDepartmentRoute.php +++ b/services/nginx/app/routes/superuserDepartmentRoute.php @@ -3,6 +3,7 @@ namespace routes; use classes\authentication; +use objects\branding_o; use objects\department_variables_o; use objects\departments_o; use objects\logs_o; @@ -50,6 +51,63 @@ class superuserDepartmentRoute 'superuser_fetch_department' => 'Fetch department' ]); + $this->put('/superuser/department/branding', function () { + global $response; + $this->requirePermission('superuser_set_department_branding'); + + $user = (new authentication())->get_user(); + if (!$user) { + (new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_SET_DEPARTMENT_BRANDING', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + + self::requireParameters(['department_id', 'branding_id']); + + $departmentId = self::getParameter('department_id'); + if (!is_int($departmentId) && !(is_string($departmentId) && preg_match('/^\d+$/', $departmentId) === 1)) { + $response->error('Department ID must be a number', 400); + } + $departmentId = (int)$departmentId; + if ($departmentId <= 0) { + $response->error('Department ID must be a positive number', 400); + } + + $department = (new departments_o())->selectId($departmentId); + if (!$department->exists()) { + $response->error('Department not found', 404); + } + + $brandingId = self::getParameter('branding_id'); + if ($brandingId === '' || $brandingId === null || $brandingId === 0 || $brandingId === '0') { + $department->branding->set(null); + } else { + if (!is_int($brandingId) && !(is_string($brandingId) && preg_match('/^\d+$/', $brandingId) === 1)) { + $response->error('Branding ID must be a number', 400); + } + + $brandingId = (int)$brandingId; + if ($brandingId <= 0) { + $response->error('Branding ID must be a positive number', 400); + } + + $branding = (new branding_o())->select($brandingId); + if (!$branding->exists()) { + $response->error('Branding not found', 404); + } + + $department->branding->set($brandingId); + } + + $department->objectChanged(); + (new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_SET_DEPARTMENT_BRANDING', 'Successfully set department branding'); + + $response->success( + (new departments_o())->getDepartmentById($departmentId, true) + ); + }, [ + 'superuser_set_department_branding' => 'Set department branding' + ]); + $this->post('/superuser/department/prices', function () { // Require the user to be logged in global $response; @@ -237,4 +295,4 @@ class superuserDepartmentRoute ]); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/superuserReplicationRoute.php b/services/nginx/app/routes/superuserReplicationRoute.php new file mode 100644 index 00000000..3a76574e --- /dev/null +++ b/services/nginx/app/routes/superuserReplicationRoute.php @@ -0,0 +1,217 @@ +get('/superuser/replication', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_view'); + $refresh = $this->toBool($this->getParameter('refresh'), false); + $response->success((new replication_manager())->summary($refresh)); + }, [ + 'superuser_replication_view' => 'View database, Redis, and MinIO replication topology and status', + ]); + + $this->post('/superuser/replication/databases', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_manage'); + $host = (new replication_manager())->addHost('database', $this->getParametersAsArray(), $this->actorUserId()); + $response->success($host, 201); + }, [ + 'superuser_replication_manage' => 'Add and manage database replication host credentials', + ]); + + $this->post('/superuser/replication/redis', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_manage'); + $host = (new replication_manager())->addHost('redis', $this->getParametersAsArray(), $this->actorUserId()); + $response->success($host, 201); + }, [ + 'superuser_replication_manage' => 'Add and manage Redis replication host credentials', + ]); + + $this->post('/superuser/replication/minio', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_manage'); + $host = (new replication_manager())->addHost('minio', $this->getParametersAsArray(), $this->actorUserId()); + $response->success($host, 201); + }, [ + 'superuser_replication_manage' => 'Add and manage MinIO replication host credentials', + ]); + + $this->post('/superuser/replication/compose-template', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_manage'); + $response->success(replication_manager::composeTemplate($this->getParametersAsArray())); + }, [ + 'superuser_replication_manage' => 'Generate Docker Compose templates for replication-ready database, Redis, and MinIO hosts', + ]); + + $this->post('/superuser/replication/test-credentials', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_manage'); + $parameters = $this->getParametersAsArray(); + $response->success((new replication_manager())->testCredentials( + (string)($parameters['kind'] ?? ''), + $parameters + )); + }, [ + 'superuser_replication_manage' => 'Test database, Redis, and MinIO replication host credentials before saving them', + ]); + + $this->post('/superuser/replication/{kind}/{id}/test', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_manage'); + $response->success((new replication_manager())->testHost( + (string)$this->fromRoute('kind'), + $this->routeId(), + $this->actorUserId() + )); + }, [ + 'superuser_replication_manage' => 'Validate database, Redis, and MinIO replication host connectivity and privileges', + ]); + + $this->post('/superuser/replication/{kind}/{id}/provision', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_manage'); + try { + $result = (new replication_manager())->provisionHost( + (string)$this->fromRoute('kind'), + $this->routeId(), + $this->actorUserId(), + true + ); + if (($result['ok'] ?? false) !== true) { + $response->error($result, 409); + } + $response->success($result); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_replication_manage' => 'Provision a database, Redis, or MinIO host as a replica of the current primary', + ]); + + $this->post('/superuser/replication/{kind}/{id}/promote', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_promote'); + try { + $response->success((new replication_manager())->promoteHost( + (string)$this->fromRoute('kind'), + $this->routeId(), + $this->actorUserId() + )); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_replication_promote' => 'Promote a healthy caught-up database, Redis, or MinIO replica to primary', + ]); + + $this->patch('/superuser/replication/{kind}/{id}', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_manage'); + try { + $response->success((new replication_manager())->renameHost( + (string)$this->fromRoute('kind'), + $this->routeId(), + $this->getParametersAsArray(), + $this->actorUserId() + )); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 400); + } + }, [ + 'superuser_replication_manage' => 'Rename database, Redis, and MinIO replication hosts', + ]); + + $this->delete('/superuser/replication/{kind}/{id}', function () { + global $response; + + $this->requireClassicSuperuserPermission('superuser_replication_remove'); + try { + $response->success((new replication_manager())->removeHost( + (string)$this->fromRoute('kind'), + $this->routeId(), + $this->actorUserId() + )); + } catch (Throwable $throwable) { + $response->error(['message' => $throwable->getMessage()], 409); + } + }, [ + 'superuser_replication_remove' => 'Remove inactive prior hosts and unhealthy database, Redis, or MinIO replicas', + ]); + } + + /** + * Replication controls alter infrastructure state and must only be used by + * a classic superuser session. Subuser bearer tokens can carry a delegated + * customer context via X-Customer-Number, so do not allow them to fall back + * to plain string user permission checks for these routes. + */ + private function requireClassicSuperuserPermission(string $permission): bool + { + global $response; + + if ((new authentication())->get_subuser() !== false) { + $response->error('Subuser sessions cannot manage replication.', 403); + } + + return $this->requirePermission($permission); + } + + 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 toBool(mixed $value, bool $default): bool + { + if (is_bool($value)) { + return $value; + } + if ($value === null) { + return $default; + } + $normalized = strtolower(trim((string)$value)); + if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { + return false; + } + return $default; + } +} diff --git a/services/nginx/app/routes/workerRoute.php b/services/nginx/app/routes/workerRoute.php index b25c81aa..b74bce9e 100644 --- a/services/nginx/app/routes/workerRoute.php +++ b/services/nginx/app/routes/workerRoute.php @@ -4,6 +4,7 @@ namespace routes; use classes\db; use classes\economic; +use classes\release_manager; use classes\router; use classes\shelly; use classes\slack; @@ -133,6 +134,7 @@ class workerRoute 'timezone' => date_default_timezone_get(), 'host' => gethostname(), 'version' => '1.0.1', + 'api_commit_sha' => release_manager::backendCommitSha(), 'routes' => $router->countRoutes(), 'redis' => [ 'host' => $REDIS_CONFIG['host'], @@ -299,4 +301,4 @@ class workerRoute // Convert to uppercase return strtoupper($cleaned); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/xlvaskUsageLogsRoute.php b/services/nginx/app/routes/xlvaskUsageLogsRoute.php index 5cb0ae19..c081de0f 100644 --- a/services/nginx/app/routes/xlvaskUsageLogsRoute.php +++ b/services/nginx/app/routes/xlvaskUsageLogsRoute.php @@ -2,11 +2,14 @@ namespace routes; +require_once WD . '/classes/xlvask_automation_service.php'; + use classes\authentication; use classes\redis; use classes\response; use classes\stripe; use classes\xlvask; +use classes\xlvask_automation_service; use objects\collected_order_invoices_o; use objects\departments_o; use objects\economic_module_orders; @@ -14,7 +17,6 @@ use objects\logs_o; use objects\orders_o; use objects\stripe_module_orders_o; use objects\stripe_payment_intents_o; -use objects\users_o; use objects\xlvask_usage_logs_o; use traits\route_t; @@ -47,6 +49,8 @@ class xlvaskUsageLogsRoute (new logs_o())->add('xlvask_usage_orders', 'global', 1, $user->id, 'LIST_XLVASK_USAGE_ORDERS', 'User accessed the list of xlvask usage orders'); $xlvask_usage_logs = new xlvask_usage_logs_o(); $xlvask = new xlvask(); + $automation_service = new xlvask_automation_service(); + $linked_order_ids_by_wash_id = []; $xlvask->new($xlvask->helpers->xlvask_usage_log)->getDepartment(); $orders_o = new orders_o(); $xlvask_usage_log = $xlvask->new($xlvask->helpers->xlvask_usage_log); @@ -62,16 +66,49 @@ class xlvaskUsageLogsRoute // Make sure the Customer is not in the default customers list ->setAdditionalWhereClause("`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log::$default_customers) . "')") ->listObjectsWithPaginationIfSet( - function ($log) use ($response_includes_items_link, $response_includes_items, $xlvask_usage_logs, $user, $xlvask) { + function ($log) use ($response_includes_items_link, $response_includes_items, $xlvask_usage_logs, $user, $xlvask, $automation_service, &$linked_order_ids_by_wash_id) { + $automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, false); // Remove the 'id' field from the log $id = (int)$log['id']; + $amount_summary = $xlvask_usage_logs->getCachedAmountSummaryFromRow($log); unset($log['id']); // Convert the 'WashItems' field from JSON to an array $log['WashItems'] = json_decode($log['WashItems'], true); + $usage_log_payload = array_intersect_key($log, array_flip([ + 'WashId', + 'CustomerId', + 'Customer', + 'VatNumber', + 'Location', + 'Hall', + 'HallId', + 'StartTime', + 'FinishTime', + 'RegistrationNumber', + 'VehicleType', + 'IdentificationType', + 'IdentificationId', + 'Info', + 'Updated', + 'Prepaid', + 'FinishStatus', + 'CustomerGuid', + 'VehicleId', + 'WashItems', + 'ignored_at', + 'ignored_by', + 'ignored_reason', + ])); // Create a new xlvask usage log object $tmp = $xlvask->new($xlvask->helpers->xlvask_usage_log); // Set the properties of the temporary object - $tmp->setProperties($log); + $tmp->setProperties($usage_log_payload); + $wash_id = (string)$tmp->WashId; + if ($wash_id !== '' && !array_key_exists($wash_id, $linked_order_ids_by_wash_id)) { + $linked_order = (new orders_o())->selectByWashId($wash_id); + $linked_order_ids_by_wash_id[$wash_id] = $linked_order !== null ? (int)$linked_order->id : null; + } + $linked_order_id = $wash_id !== '' ? $linked_order_ids_by_wash_id[$wash_id] : null; // Define the result structure $isEligibleForAutomaticContinuance = $tmp->isEligibleForAutomaticContinuance(true); // Generate a fast link key for the order, used to retrieve the order with items later. @@ -87,6 +124,9 @@ class xlvaskUsageLogsRoute // Return the result $tmp_res = ($isEligibleForAutomaticContinuance ? (new orders_o())->simulateOrderFromXLVask($tmp, $response_includes_items) : []); $tmp_res['order']['customer_name'] = $tmp->Customer; // Add the customer name to the order + $tmp_res['order']['total_net_amount'] = $amount_summary['total_net_amount']; + $tmp_res['order']['xlvask_primary_product_name'] = $amount_summary['primary_product_name']; + $tmp_res['order']['xlvask_amount_cached'] = $amount_summary['cached']; // Clear memory unset($tmp); unset($log); @@ -94,7 +134,10 @@ class xlvaskUsageLogsRoute return [ 'id' => $id, // Return the ID of the log 'fast_link_key' => $fast_link_key ?? null, // Return the fast link key if it was generated + 'automation' => $automation, ...$tmp_res['order'], // Return the simulated order from XLVask (with or without items) + 'usage_log_id' => $id, + 'linked_order_id' => $linked_order_id, ]; }, $xlvask_usage_logs->forceRestrictFilters( @@ -120,6 +163,155 @@ class xlvaskUsageLogsRoute ] ); + $this->patch('/modules/xlvask/services/usage/orders/{id}/ignore', function () { + global $db, $response; + $this->requirePermission('ignore_xlvask_usage_order'); + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $id = (int)($this->fromRoute('id') ?? 0); + if ($id < 1) { + $response->error('Invalid XL Vask usage log id', 400); + return; + } + + $reason = $this->isParametersSet(['reason']) ? trim((string)$this->getParameter('reason')) : null; + $reasonSql = $reason === null || $reason === '' + ? 'NULL' + : "'" . $db->escape_string($reason) . "'"; + + (new xlvask_usage_logs_o())->structure(); + $db->query( + "UPDATE xlvask_usage_logs + SET ignored_at = NOW(), + ignored_by = " . (int)$user->id . ", + ignored_reason = {$reasonSql} + WHERE id = {$id}" + ); + + $response->success([ + 'id' => $id, + 'ignored' => true, + ]); + }, + [ + 'ignore_xlvask_usage_order' => 'Ignore an XL Vask usage log for invoice period flagging', + ] + ); + + $this->post('/modules/xlvask/services/usage/orders/automation/run', function () { + global $response; + $this->requirePermission('manage_xlvask_usage_automation'); + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $ids = $this->isParametersSet(['ids']) ? $this->getParameter('ids') : []; + if (!is_array($ids)) { + $ids = []; + } + + $dateFrom = $this->isParametersSet(['dateFrom']) ? (string)$this->getParameter('dateFrom') : null; + $dateTo = $this->isParametersSet(['dateTo']) ? (string)$this->getParameter('dateTo') : null; + $limit = $this->isParametersSet(['limit']) ? (int)$this->getParameter('limit') : 100; + + $response->success( + (new xlvask_automation_service())->runPending($dateFrom, $dateTo, $ids, $limit, (int)$user->id) + ); + }, + [ + 'manage_xlvask_usage_automation' => 'Evaluate and execute XL Vask usage-log automation', + ] + ); + + $this->post('/modules/xlvask/services/usage/orders/{id}/automation/evaluate', function () { + global $response; + $this->requirePermission('manage_xlvask_usage_automation'); + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $id = (int)($this->fromRoute('id') ?? 0); + if ($id < 1) { + $response->error('Invalid XL Vask usage log id', 400); + return; + } + + $response->success( + (new xlvask_automation_service())->evaluateUsageLogById($id, (int)$user->id, true) + ); + }, + [ + 'manage_xlvask_usage_automation' => 'Evaluate XL Vask usage-log automation', + ] + ); + + $this->post('/modules/xlvask/services/usage/orders/{id}/automation/accept', function () { + global $response; + $this->requirePermission('manage_xlvask_usage_automation'); + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $id = (int)($this->fromRoute('id') ?? 0); + if ($id < 1) { + $response->error('Invalid XL Vask usage log id', 400); + return; + } + + $suggestionId = $this->isParametersSet(['suggestion_id']) ? (int)$this->getParameter('suggestion_id') : null; + $reason = $this->isParametersSet(['reason']) ? trim((string)$this->getParameter('reason')) : null; + + $response->success( + (new xlvask_automation_service())->acceptUsageLogById($id, (int)$user->id, $suggestionId, $reason) + ); + }, + [ + 'manage_xlvask_usage_automation' => 'Accept an XL Vask usage-log automation suggestion', + ] + ); + + $this->post('/modules/xlvask/services/usage/orders/{id}/automation/deny', function () { + global $response; + $this->requirePermission('manage_xlvask_usage_automation'); + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $id = (int)($this->fromRoute('id') ?? 0); + if ($id < 1) { + $response->error('Invalid XL Vask usage log id', 400); + return; + } + + $suggestionId = $this->isParametersSet(['suggestion_id']) ? (int)$this->getParameter('suggestion_id') : null; + $reason = $this->isParametersSet(['reason']) ? trim((string)$this->getParameter('reason')) : null; + + $response->success( + (new xlvask_automation_service())->denyUsageLogById($id, (int)$user->id, $suggestionId, $reason) + ); + }, + [ + 'manage_xlvask_usage_automation' => 'Deny an XL Vask usage-log automation suggestion', + ] + ); + $this->get('/modules/xlvask/services/usage/orders/fast-link', function () { global $response; self::requireParameters([ @@ -204,4 +396,4 @@ class xlvaskUsageLogsRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/tests/Api/ApiFixturesCleanupTest.php b/services/nginx/app/tests/Api/ApiFixturesCleanupTest.php index 00835267..233b21a8 100644 --- a/services/nginx/app/tests/Api/ApiFixturesCleanupTest.php +++ b/services/nginx/app/tests/Api/ApiFixturesCleanupTest.php @@ -70,6 +70,50 @@ it('removes generated customer traces during fixture cleanup', function (): void ]); }); +it('restores preserved module config rows during fixture cleanup', function (): void { + $db = api_test_runtime()->db(); + $module = 'fixture_cleanup'; + $variable = 'preserve_module_config_' . bin2hex(random_bytes(4)); + $moduleEscaped = $db->real_escape_string($module); + $variableEscaped = $db->real_escape_string($variable); + $ended = false; + + try { + $db->query( + "INSERT INTO `module_config` (`module`, `variable`, `value`, `type`, `created_at`, `updated_at`) " . + "VALUES ('{$moduleEscaped}', '{$variableEscaped}', '600100', 'int', '2026-04-14 12:00:00', '2026-04-14 12:00:00')" + ); + + api_fixtures()->preserveModuleConfig($module, $variable); + + $db->query( + "UPDATE `module_config` " . + "SET `value` = NULL, `type` = 'string', `updated_at` = '2026-04-14 12:05:00' " . + "WHERE `module` = '{$moduleEscaped}' AND `variable` = '{$variableEscaped}'" + ); + + api_test_runtime()->endTest(); + $ended = true; + + $row = api_fixture_cleanup_module_config_row($db, $module, $variable); + + expect($row) + ->not->toBeNull() + ->and($row['value'] ?? null)->toBe('600100') + ->and($row['type'] ?? null)->toBe('int') + ->and($row['created_at'] ?? null)->toBe('2026-04-14 12:00:00') + ->and($row['updated_at'] ?? null)->toBe('2026-04-14 12:00:00'); + } finally { + if (!$ended) { + api_test_runtime()->endTest(); + } + + $db->query( + "DELETE FROM `module_config` WHERE `module` = '{$moduleEscaped}' AND `variable` = '{$variableEscaped}'" + ); + } +}); + function api_fixture_cleanup_ensure_optional_trace_tables(mysqli $db): void { $statements = [ @@ -450,6 +494,24 @@ function api_fixture_cleanup_count(mysqli $db, string $sql): int return (int)($row['c'] ?? 0); } +function api_fixture_cleanup_module_config_row(mysqli $db, string $module, string $variable): ?array +{ + $moduleEscaped = $db->real_escape_string($module); + $variableEscaped = $db->real_escape_string($variable); + $result = $db->query( + "SELECT * FROM `module_config` WHERE `module` = '{$moduleEscaped}' AND `variable` = '{$variableEscaped}' LIMIT 1" + ); + + if ($result === false) { + throw new RuntimeException('Fixture cleanup module_config query failed.'); + } + + $row = $result->fetch_assoc(); + $result->free(); + + return $row ?: null; +} + /** * @param array $data */ diff --git a/services/nginx/app/tests/Api/AuthApiTest.php b/services/nginx/app/tests/Api/AuthApiTest.php index dd131a13..24d76508 100644 --- a/services/nginx/app/tests/Api/AuthApiTest.php +++ b/services/nginx/app/tests/Api/AuthApiTest.php @@ -77,6 +77,7 @@ it('returns the cached auth session payload for a valid token', function (): voi ] ); api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', '445566', 'int'); + api_fixtures()->setModuleConfig('economic', 'defaultDepartmentId', '75', 'int'); $response = api_client()->get('/auth/session', $session['headers']); @@ -92,13 +93,16 @@ it('returns the cached auth session payload for a valid token', function (): voi ->and($response->data()['permissions']) ->toContain('list_departments') ->and($response->data()['runtime_config']['economic']['transaction_draft_customer_number'] ?? null) - ->toBe(445566); + ->toBe(445566) + ->and($response->data()['runtime_config']['economic']['default_distribution_department_id'] ?? null) + ->toBe(75); }); it('includes economic runtime config for uncached auth sessions', function (): void { api_test_covers('GET /auth/session', 'happy'); api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', '556677', 'int'); + api_fixtures()->setModuleConfig('economic', 'defaultDepartmentId', '65', 'int'); $session = api_fixtures()->createUserSession(['list_departments'], [ 'display_name' => 'Fresh Session User', ]); @@ -114,7 +118,9 @@ it('includes economic runtime config for uncached auth sessions', function (): v ->toBeArray() ->toHaveKey('customer_number', $session['user']['customer_number']) ->and($response->data()['runtime_config']['economic']['transaction_draft_customer_number'] ?? null) - ->toBe(556677); + ->toBe(556677) + ->and($response->data()['runtime_config']['economic']['default_distribution_department_id'] ?? null) + ->toBe(65); }); it('rejects invalid auth session tokens', function (): void { diff --git a/services/nginx/app/tests/Api/BrandingApiTest.php b/services/nginx/app/tests/Api/BrandingApiTest.php new file mode 100644 index 00000000..5a097f91 --- /dev/null +++ b/services/nginx/app/tests/Api/BrandingApiTest.php @@ -0,0 +1,233 @@ +createUserSession([ + 'list_branding_options', + 'add_branding_option', + 'edit_branding_option', + ]); + + $createPayload = [ + 'name' => 'API Brand ' . uniqid('', false), + 'description' => 'Created through the branding API', + 'cvr' => 41004355, + 'address' => 'Skagerrakvej 15, 6715 Esbjerg', + 'phone_country_code' => 45, + 'phone' => 76123456, + 'email' => 'brand@example.test', + 'website' => 'https://brand.example.test', + 'banner' => 'https://cdn.example.test/banner.png', + 'logo' => 'https://cdn.example.test/logo.png', + 'favicon' => 'https://cdn.example.test/favicon.ico', + 'signature' => 'https://cdn.example.test/signature.png', + ]; + + $createResponse = api_client()->post('/branding', $createPayload, $session['headers']); + + $createResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $createdId = (int)($createResponse->data()['id'] ?? 0); + expect($createdId)->toBeGreaterThan(0); + api_fixtures()->cleanupDeleteById('branding', $createdId); + + $singleResponse = api_client()->get('/branding?id=' . $createdId, $session['headers']); + + $singleResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($singleResponse->data()) + ->toBeArray() + ->toHaveKey('id', $createdId) + ->toHaveKey('name', $createPayload['name']) + ->toHaveKey('logo', $createPayload['logo']); + + $listResponse = api_client()->get('/branding', $session['headers']); + + $listResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $listedIds = array_map( + static fn(array $branding): int => (int)($branding['id'] ?? 0), + is_array($listResponse->data()) ? $listResponse->data() : [] + ); + expect($listedIds)->toContain($createdId); + + $updatePayload = [ + 'id' => $createdId, + 'name' => 'Updated Brand', + 'description' => 'Updated description', + 'cvr' => 43423010, + 'address' => 'Updatedvej 1, 1000 Kobenhavn', + 'phone_country_code' => 46, + 'phone' => 87654321, + 'email' => 'updated@example.test', + 'website' => 'https://updated.example.test', + 'banner' => 'https://cdn.example.test/updated-banner.png', + 'logo' => 'https://cdn.example.test/updated-logo.png', + 'favicon' => 'https://cdn.example.test/updated-favicon.ico', + 'signature' => '', + ]; + + $updateResponse = api_client()->put('/branding', $updatePayload, $session['headers']); + + $updateResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $updatedRow = api_fixtures()->fetchRowById('branding', $createdId); + expect($updatedRow)->not->toBeNull(); + expect($updatedRow['name'] ?? null)->toBe('Updated Brand'); + expect((int)($updatedRow['cvr'] ?? 0))->toBe(43423010); + expect($updatedRow['logo'] ?? null)->toBe('https://cdn.example.test/updated-logo.png'); + expect(array_key_exists('signature', $updatedRow))->toBeTrue(); + expect($updatedRow['signature'])->toBeNull(); +}); + +it('rejects branding requests without permissions or valid input', function (): void { + api_test_covers('GET /branding', 'auth'); + api_test_covers('POST /branding', 'failure'); + api_test_covers('PUT /branding', 'failure'); + + $unauthorizedSession = api_fixtures()->createUserSession([]); + + api_client()->get('/branding', $unauthorizedSession['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['list_branding_options']); + + $createSession = api_fixtures()->createUserSession(['add_branding_option']); + + api_client()->post('/branding', [ + 'name' => 'Invalid Brand', + 'description' => 'Missing CVR', + ], $createSession['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Missing required parameters: cvr'); + + api_client()->post('/branding', [ + 'name' => 'Invalid Brand', + 'description' => 'Bad CVR', + 'cvr' => 'not-a-number', + ], $createSession['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('cvr must be an integer'); + + $editSession = api_fixtures()->createUserSession(['edit_branding_option']); + + api_client()->put('/branding', [ + 'id' => 99999999, + 'name' => 'Missing Brand', + ], $editSession['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Invalid id'); +}); + +it('assigns and clears department branding for superusers', function (): void { + api_test_covers('PUT /superuser/department/branding', 'happy'); + + $branding = api_fixtures()->createBranding([ + 'name' => 'Assignable Brand', + 'description' => 'Brand for assignment', + ]); + $department = api_fixtures()->createDepartment([ + 'name' => 'Branding Department', + ]); + $session = api_fixtures()->createUserSession([ + 'superuser_set_department_branding', + 'superuser_fetch_department', + ]); + + $assignResponse = api_client()->put('/superuser/department/branding', [ + 'department_id' => $department['id'], + 'branding_id' => $branding['id'], + ], $session['headers']); + + $assignResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $assignedRow = api_fixtures()->fetchRowById('departments', (int)$department['id']); + expect((int)($assignedRow['branding'] ?? 0))->toBe((int)$branding['id']); + + $departmentResponse = api_client()->get('/superuser/department?department_id=' . $department['id'], $session['headers']); + + $departmentResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + expect((int)($departmentResponse->data()['branding'] ?? 0))->toBe((int)$branding['id']); + + $clearResponse = api_client()->put('/superuser/department/branding', [ + 'department_id' => $department['id'], + 'branding_id' => null, + ], $session['headers']); + + $clearResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $clearedRow = api_fixtures()->fetchRowById('departments', (int)$department['id']); + expect(array_key_exists('branding', $clearedRow))->toBeTrue(); + expect($clearedRow['branding'])->toBeNull(); +}); + +it('rejects invalid department branding assignments', function (): void { + api_test_covers('PUT /superuser/department/branding', 'failure'); + + $department = api_fixtures()->createDepartment(); + $unauthorizedSession = api_fixtures()->createUserSession([]); + + api_client()->put('/superuser/department/branding', [ + 'department_id' => $department['id'], + 'branding_id' => null, + ], $unauthorizedSession['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['superuser_set_department_branding']); + + $session = api_fixtures()->createUserSession(['superuser_set_department_branding']); + + api_client()->put('/superuser/department/branding', [ + 'department_id' => $department['id'], + 'branding_id' => 99999999, + ], $session['headers']) + ->assertStatus(404) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Branding not found'); + + api_client()->put('/superuser/department/branding', [ + 'department_id' => 99999999, + 'branding_id' => null, + ], $session['headers']) + ->assertStatus(404) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Department not found'); +}); diff --git a/services/nginx/app/tests/Api/CollectedInvoiceMonthlySplitApiTest.php b/services/nginx/app/tests/Api/CollectedInvoiceMonthlySplitApiTest.php new file mode 100644 index 00000000..336129dc --- /dev/null +++ b/services/nginx/app/tests/Api/CollectedInvoiceMonthlySplitApiTest.php @@ -0,0 +1,347 @@ +queryOne('SELECT invoice_collection_id FROM orders WHERE id = ' . $orderId . ' LIMIT 1'); + return (int)($row['invoice_collection_id'] ?? 0); +} + +function monthly_split_invoice_row(int $invoiceCollectionId): array +{ + return api_test_runtime()->queryOne('SELECT id, created_at, closed_at FROM collected_order_invoices WHERE id = ' . $invoiceCollectionId . ' LIMIT 1') ?? []; +} + +function monthly_split_customer_collection_count(int $customerNumber): int +{ + $row = api_test_runtime()->queryOne('SELECT COUNT(*) AS count FROM collected_order_invoices WHERE customer_number = ' . $customerNumber); + return (int)($row['count'] ?? 0); +} + +function monthly_split_cleanup_collections(array $invoiceCollectionIds): void +{ + $ids = array_values(array_unique(array_filter(array_map('intval', $invoiceCollectionIds), static fn(int $id): bool => $id > 0))); + if ($ids === []) { + return; + } + + api_test_runtime()->db()->query('UPDATE orders SET invoice_collection_id = NULL WHERE invoice_collection_id IN (' . implode(',', $ids) . ')'); + api_test_runtime()->db()->query('DELETE FROM collected_order_invoices WHERE id IN (' . implode(',', $ids) . ')'); +} + +it('previews monthly split changes without moving orders or creating collections', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'preview'); + + $customer = api_fixtures()->createUser(['display_name' => 'Preview Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $invoiceCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + ]); + $marchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-03-15 10:00:00', + ]); + $aprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-04-02 10:00:00', + ]); + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + $collectionCountBefore = monthly_split_customer_collection_count((int)$customer['customer_number']); + + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-04-30', + 'preview' => true, + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + $months = (array)($payload['changed'][0]['months'] ?? []); + expect($payload['preview'] ?? null)->toBeTrue() + ->and($payload['processed_count'] ?? null)->toBe(1) + ->and($payload['changed_count'] ?? null)->toBe(1) + ->and($payload['changed'][0]['created_invoice_collection_ids'] ?? null)->toBe([]) + ->and($months[0]['month'] ?? null)->toBe('2096-03') + ->and($months[0]['will_create_collection'] ?? null)->toBeFalse() + ->and($months[0]['target_invoice_collection_id'] ?? null)->toBe((int)$invoiceCollection['id']) + ->and($months[0]['closed_at'] ?? null)->toBeNull() + ->and($months[1]['month'] ?? null)->toBe('2096-04') + ->and($months[1]['will_create_collection'] ?? null)->toBeTrue() + ->and($months[1]['target_invoice_collection_id'] ?? null)->toBeNull() + ->and($months[1]['closed_at'] ?? null)->toBeNull() + ->and(monthly_split_customer_collection_count((int)$customer['customer_number']))->toBe($collectionCountBefore) + ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) + ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe((int)$invoiceCollection['id']); +}); + +it('splits a selected March and April collected invoice into monthly collections', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'happy'); + + $customer = api_fixtures()->createUser(['display_name' => 'Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $invoiceCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + 'created_at' => '2096-03-01 00:00:01', + ]); + $marchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-03-15 10:00:00', + ]); + $aprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-04-02 10:00:00', + ]); + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + $createdCollectionIds = []; + + try { + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-04-30', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + $createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []); + $aprilCollectionId = (int)($createdCollectionIds[0] ?? 0); + + expect($payload['processed_count'] ?? null)->toBe(1) + ->and($payload['changed_count'] ?? null)->toBe(1) + ->and($payload['skipped_count'] ?? null)->toBe(0) + ->and($aprilCollectionId)->toBeGreaterThan(0) + ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) + ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe($aprilCollectionId) + ->and(monthly_split_invoice_row((int)$invoiceCollection['id'])['created_at'] ?? null)->toBe('2096-03-01 00:00:01') + ->and(monthly_split_invoice_row((int)$invoiceCollection['id'])['closed_at'] ?? null)->toBeNull() + ->and(monthly_split_invoice_row($aprilCollectionId)['created_at'] ?? null)->toBe('2096-04-01 00:00:01') + ->and(monthly_split_invoice_row($aprilCollectionId)['closed_at'] ?? null)->toBeNull(); + } finally { + monthly_split_cleanup_collections($createdCollectionIds); + } +}); + +it('sets closed_at to month end when split month has ended', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'closed-at'); + + $customer = api_fixtures()->createUser(['display_name' => 'Ended Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $invoiceCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + 'created_at' => '2001-03-01 00:00:01', + ]); + $marchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2001-03-15 10:00:00', + ]); + $aprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2001-04-02 10:00:00', + ]); + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + $createdCollectionIds = []; + + try { + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2001-03-01', + 'dateTo' => '2001-04-30', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + $createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []); + $aprilCollectionId = (int)($createdCollectionIds[0] ?? 0); + + expect($payload['changed_count'] ?? null)->toBe(1) + ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) + ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe($aprilCollectionId) + ->and(monthly_split_invoice_row((int)$invoiceCollection['id'])['closed_at'] ?? null)->toBe('2001-03-31 23:59:59') + ->and(monthly_split_invoice_row($aprilCollectionId)['closed_at'] ?? null)->toBe('2001-04-30 23:59:59'); + } finally { + monthly_split_cleanup_collections($createdCollectionIds); + } +}); + +it('splits the whole affected collection even when only one month is selected', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'partial'); + + $customer = api_fixtures()->createUser(['display_name' => 'Partial Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $invoiceCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + ]); + $marchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-03-20 09:00:00', + ]); + $aprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-04-10 09:00:00', + ]); + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + $createdCollectionIds = []; + + try { + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-03-31', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + $createdCollectionIds = (array)($payload['changed'][0]['created_invoice_collection_ids'] ?? []); + $aprilCollectionId = (int)($createdCollectionIds[0] ?? 0); + + expect($payload['changed_count'] ?? null)->toBe(1) + ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) + ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe($aprilCollectionId); + } finally { + monthly_split_cleanup_collections($createdCollectionIds); + } +}); + +it('does not affect booked collected invoices', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'booked-skip'); + + $customer = api_fixtures()->createUser(['display_name' => 'Booked Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $invoiceCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + 'booked_invoice_id' => 987654, + ]); + $marchOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-03-05 12:00:00', + ]); + $aprilOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $invoiceCollection['id'], + 'created_at' => '2096-04-05 12:00:00', + ]); + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-03-31', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + expect($payload['changed_count'] ?? null)->toBe(0) + ->and($payload['skipped_count'] ?? null)->toBe(1) + ->and((string)($payload['skipped'][0]['message'] ?? ''))->toContain('booked') + ->and(monthly_split_order_collection_id((int)$marchOrder['id']))->toBe((int)$invoiceCollection['id']) + ->and(monthly_split_order_collection_id((int)$aprilOrder['id']))->toBe((int)$invoiceCollection['id']); +}); + +it('skips draft linked, Stripe, and single month collections', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'skip'); + + $customer = api_fixtures()->createUser(['display_name' => 'Skipped Monthly Split Customer']); + $department = api_fixtures()->createDepartment(); + $draftCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + 'external_id' => 'draft-external-reference', + ]); + $stripeCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + 'processor' => 2, + ]); + $singleMonthCollection = api_fixtures()->createInvoiceCollection([ + 'customer_number' => $customer['customer_number'], + ]); + + foreach ([$draftCollection, $stripeCollection] as $collection) { + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $collection['id'], + 'created_at' => '2096-03-05 12:00:00', + ]); + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $collection['id'], + 'created_at' => '2096-04-05 12:00:00', + ]); + } + + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'invoice_collection_id' => $singleMonthCollection['id'], + 'created_at' => '2096-03-10 12:00:00', + ]); + + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + + $response = api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-03-01', + 'dateTo' => '2096-03-31', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + expect($payload['processed_count'] ?? null)->toBe(3) + ->and($payload['changed_count'] ?? null)->toBe(0) + ->and($payload['skipped_count'] ?? null)->toBe(3); +}); + +it('rejects invalid monthly split date ranges', function (): void { + api_test_covers('POST /collected-invoices/split-by-month', 'failure'); + + $session = api_fixtures()->createUserSession(['split_collected_invoice']); + + api_client()->post('/collected-invoices/split-by-month', [ + 'dateFrom' => '2096-04-30', + 'dateTo' => '2096-03-01', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false); +}); diff --git a/services/nginx/app/tests/Api/DepartmentsApiTest.php b/services/nginx/app/tests/Api/DepartmentsApiTest.php index 5234a477..2f4cb73a 100644 --- a/services/nginx/app/tests/Api/DepartmentsApiTest.php +++ b/services/nginx/app/tests/Api/DepartmentsApiTest.php @@ -20,6 +20,11 @@ it('lists only visible departments and can return a single department with the s 'name' => 'Hidden Department', 'visible' => 0, ]); + $archivedDepartment = api_fixtures()->createDepartment([ + 'name' => 'Archived Department', + 'visible' => 1, + 'archived' => 1, + ]); $webhookDepartment = api_fixtures()->createDepartment([ 'name' => 'Webhook Department', 'slack_webhook' => 'https://hooks.slack.test/example', @@ -41,7 +46,8 @@ it('lists only visible departments and can return a single department with the s expect($departmentIds) ->toContain($visibleDepartment['id']) ->toContain($webhookDepartment['id']) - ->not->toContain($hiddenDepartment['id']); + ->not->toContain($hiddenDepartment['id']) + ->not->toContain($archivedDepartment['id']); $singleResponse = api_client()->get('/departments?id=' . $webhookDepartment['id'], $session['headers']); @@ -56,6 +62,79 @@ it('lists only visible departments and can return a single department with the s ->toHaveKey('slack_webhook', 'https://hooks.slack.test/example'); }); +it('allows superusers to filter archived departments', function (): void { + api_test_covers('GET /departments', 'happy'); + + $session = api_fixtures()->createUserSession([ + 'list_departments', + 'superuser_fetch_department', + ]); + + $activeDepartment = api_fixtures()->createDepartment([ + 'name' => 'Active Department', + 'visible' => 1, + 'archived' => 0, + ]); + $archivedDepartment = api_fixtures()->createDepartment([ + 'name' => 'Archived Department', + 'visible' => 1, + 'archived' => 1, + ]); + + $response = api_client()->get('/departments?filters=archived:1', $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $departmentIds = array_map( + static fn(array $department): int => (int)($department['id'] ?? 0), + is_array($response->data()) ? $response->data() : [] + ); + + expect($departmentIds) + ->toContain($archivedDepartment['id']) + ->not->toContain($activeDepartment['id']); + + foreach ($response->data() as $department) { + expect((bool)($department['archived'] ?? false))->toBeTrue(); + } +}); + +it('does not allow regular department listings to reveal archived departments through filters', function (): void { + api_test_covers('GET /departments', 'auth'); + + $session = api_fixtures()->createUserSession(['list_departments']); + + $activeDepartment = api_fixtures()->createDepartment([ + 'name' => 'Regular Active Department', + 'visible' => 1, + 'archived' => 0, + ]); + $archivedDepartment = api_fixtures()->createDepartment([ + 'name' => 'Regular Archived Department', + 'visible' => 1, + 'archived' => 1, + ]); + + $response = api_client()->get('/departments?filters=archived:1', $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $departmentIds = array_map( + static fn(array $department): int => (int)($department['id'] ?? 0), + is_array($response->data()) ? $response->data() : [] + ); + + expect($departmentIds) + ->toContain($activeDepartment['id']) + ->not->toContain($archivedDepartment['id']); +}); + it('rejects department listing when the permission is missing', function (): void { api_test_covers('GET /departments', 'auth'); @@ -137,6 +216,7 @@ it('updates departments through the real endpoint', function (): void { 'name' => 'Updated Department', 'description' => 'Updated description', 'order_priority' => 5, + 'archived' => true, ], $session['headers']); $response @@ -151,6 +231,7 @@ it('updates departments through the real endpoint', function (): void { expect($row['name'] ?? null)->toBe('Updated Department'); expect($row['description'] ?? null)->toBe('Updated description'); expect((int)($row['order_priority'] ?? 0))->toBe(5); + expect((int)($row['archived'] ?? 0))->toBe(1); }); it('rejects invalid department update requests', function (): void { diff --git a/services/nginx/app/tests/Api/EconomicDraftCustomerApiTest.php b/services/nginx/app/tests/Api/EconomicDraftCustomerApiTest.php index cafe763f..b5bfd3ab 100644 --- a/services/nginx/app/tests/Api/EconomicDraftCustomerApiTest.php +++ b/services/nginx/app/tests/Api/EconomicDraftCustomerApiTest.php @@ -41,6 +41,7 @@ it('lists the draft customer config entry in economic config responses', functio it('round-trips the draft customer config value through economic config updates', function (): void { api_test_covers('POST /economic/config', 'happy'); + api_fixtures()->preserveModuleConfig('economic', 'transactionDraftCustomerNumber'); $session = api_fixtures()->createUserSession(['economic_config']); api_client()->post('/economic/config', [ @@ -82,6 +83,33 @@ it('round-trips the draft customer config value through economic config updates' expect($clearedEntry['value'])->toBeNull(); }); +it('round-trips the default distribution department config value through economic config updates', function (): void { + api_test_covers('POST /economic/config', 'happy'); + + api_fixtures()->preserveModuleConfig('economic', 'defaultDepartmentId'); + $session = api_fixtures()->createUserSession(['economic_config']); + + api_client()->post('/economic/config', [ + 'variable' => 'defaultDepartmentId', + 'value' => 75, + ], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $configuredResponse = api_client()->get('/economic/config?variable=defaultDepartmentId', $session['headers']); + $configuredResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $configuredEntry = economic_draft_customer_find_config_entry((array)$configuredResponse->data(), 'defaultDepartmentId'); + expect($configuredEntry) + ->not->toBeNull() + ->and($configuredEntry['type'] ?? null)->toBe('int') + ->and($configuredEntry['value'] ?? null)->toBe(75); +}); + it('rejects order draft exports for the configured draft customer', function (): void { api_test_covers('POST /economic/invoice/draft/export', 'failure'); diff --git a/services/nginx/app/tests/Api/EdgeGatewayAgentApiTest.php b/services/nginx/app/tests/Api/EdgeGatewayAgentApiTest.php index d6602862..f56d354a 100644 --- a/services/nginx/app/tests/Api/EdgeGatewayAgentApiTest.php +++ b/services/nginx/app/tests/Api/EdgeGatewayAgentApiTest.php @@ -82,6 +82,10 @@ it('serves installer artifacts and recovers gateway runtime status after fresh h 'status' => 'ONLINE', 'hostname' => 'edge-agent-api', 'metadata' => [ + 'agent_instance_id' => 'edge-agent-api-test', + 'broker_connected' => true, + 'broker_url' => 'wss://broker.example.test/edge-broker', + 'broker_last_connected_at' => '2026-04-08T10:05:00+00:00', 'system_metrics' => [ 'cpu_percent' => 21, 'memory_mb' => 128, @@ -96,7 +100,10 @@ it('serves installer artifacts and recovers gateway runtime status after fresh h ->assertSuccess(); expect($heartbeatResponse->data()) - ->toHaveKey('status', 'ONLINE'); + ->toHaveKey('status', 'ONLINE') + ->and($heartbeatResponse->data()['broker_url'] ?? null) + ->toBeString() + ->toContain('/edge-broker'); $recoveredDetail = api_client()->get('/edge-gateways/' . $gatewayId, $session['headers']); @@ -108,7 +115,13 @@ it('serves installer artifacts and recovers gateway runtime status after fresh h expect($recoveredDetail->data()) ->toHaveKey('status', 'ONLINE') ->and($recoveredDetail->data()['metadata']['system_metrics']['cpu_percent'] ?? null) - ->toBe(21); + ->toBe(21) + ->and($recoveredDetail->data()['metadata']['broker_presence']['connected'] ?? null) + ->toBeTrue() + ->and($recoveredDetail->data()['channel_status']['broker']['connected'] ?? null) + ->toBeTrue() + ->and($recoveredDetail->data()['channel_status']['command']['preferred'] ?? null) + ->toBe(edge_gateway_manager::DELIVERY_CHANNEL_BROKER); }); it('polls operations and commands, submits results, and records broker presence for task pages', function (): void { diff --git a/services/nginx/app/tests/Api/EdgeGatewayBrokerApiTest.php b/services/nginx/app/tests/Api/EdgeGatewayBrokerApiTest.php index d86231b7..dad86162 100644 --- a/services/nginx/app/tests/Api/EdgeGatewayBrokerApiTest.php +++ b/services/nginx/app/tests/Api/EdgeGatewayBrokerApiTest.php @@ -95,6 +95,48 @@ it('validates broker sessions and ingests presence, telemetry, logs, and shell l expect($logEntry->data()) ->toHaveKey('message', 'Broker forwarded a live gateway log.'); + $relayLogEntry = api_client()->post( + '/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/logs', + [ + 'level' => 'INFO', + 'stream' => 'relay', + 'source' => 'RELAY_DISPATCH', + 'message' => 'MACHINE ON Roskilde Maskine handled by local via BROKER_FAST_PATH', + 'context' => [ + 'module' => 'selfserve', + 'module_responsible' => 'selfserve', + 'reason' => 'Broker relayed machine start', + 'handler' => 'local', + 'delivery_channel' => 'BROKER_FAST_PATH', + 'relay_id' => 'M-7', + 'relay_name' => 'Roskilde Maskine', + 'relay_role' => 'MACHINE', + 'description' => 'MACHINE ON Roskilde Maskine handled by local via BROKER_FAST_PATH', + 'associated' => [ + 'admin_user_id' => 77, + 'customer_number' => 700123, + ], + 'signal' => [ + 'command_type' => 'SET_RELAY_STATE', + 'request' => [ + 'relayId' => 'M-7', + 'on' => true, + ], + ], + 'response' => [ + 'online' => true, + 'on' => true, + ], + ], + ], + edge_test_broker_headers() + ); + + $relayLogEntry + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + $streamSession = api_client()->post( '/edge-gateways/' . (int)$gateway['id'] . '/stream-session', ['scopes' => ['logs', 'statistics', 'tasks']], @@ -215,9 +257,22 @@ it('validates broker sessions and ingests presence, telemetry, logs, and shell l expect(collect_gateway_messages($logsPage->data()['log_entries'] ?? [])) ->toContain('Broker forwarded a live gateway log.'); + expect(collect_gateway_messages($logsPage->data()['relay_logs'] ?? [])) + ->toContain('MACHINE ON Roskilde Maskine handled by local via BROKER_FAST_PATH'); + expect($logsPage->data()['relay_logs'][0]['context']['associated']['customer_number'] ?? null) + ->toBe(700123) + ->and($logsPage->data()['relay_logs'][0]['context']['module_responsible'] ?? null) + ->toBe('selfserve') + ->and($logsPage->data()['relay_logs'][0]['context']['relay_name'] ?? null) + ->toBe('Roskilde Maskine') + ->and($logsPage->data()['relay_logs'][0]['context']['relay_role'] ?? null) + ->toBe('MACHINE') + ->and($logsPage->data()['relay_logs'][0]['context']['reason'] ?? null) + ->toBe('Broker relayed machine start'); expect(collect_gateway_messages($logsPage->data()['timeline'] ?? [])) ->toContain('GATEWAY_SHELL_SESSION_OPENED') - ->toContain('GATEWAY_SHELL_SESSION_CLOSED'); + ->toContain('GATEWAY_SHELL_SESSION_CLOSED') + ->toContain('MACHINE ON Roskilde Maskine handled by local via BROKER_FAST_PATH'); expect($logsPage->data()['shell_sessions'][0]['transcript'] ?? null) ->toBe("edge-broker-shell\n"); diff --git a/services/nginx/app/tests/Api/EdgeGatewayConfigApiTest.php b/services/nginx/app/tests/Api/EdgeGatewayConfigApiTest.php new file mode 100644 index 00000000..cad8b978 --- /dev/null +++ b/services/nginx/app/tests/Api/EdgeGatewayConfigApiTest.php @@ -0,0 +1,129 @@ +createDepartment([ + 'name' => 'Edge Gateway Config Department', + ]); + $session = api_fixtures()->createEdgeOperatorSession((int)$department['id']); + $gateway = api_fixtures()->createClaimedEdgeGateway([ + 'department_id' => (int)$department['id'], + 'label' => 'Config Gateway', + ]); + + api_fixtures()->setModuleConfig('edgegateway', 'enabled', 'true', 'bool'); + api_fixtures()->setModuleConfig('edgegateway', 'default_release_channel', 'stable', 'string'); + api_fixtures()->setModuleConfig('edgegateway', 'default_update_window', '02:00-04:00', 'string'); + api_fixtures()->setModuleConfig('edgegateway', 'broker_url', 'http://edge-broker:4300', 'string'); + api_fixtures()->setModuleConfig('edgegateway', 'public_broker_url', '', 'string'); + api_fixtures()->setModuleConfig('edgegateway', 'broker_auth_mode', 'manager', 'string'); + api_fixtures()->setModuleConfig('edgegateway', 'broker_shared_secret', edge_test_broker_secret(), 'string'); + + $update = api_client()->post('/edgegateway/config', [ + 'enabled' => true, + 'default_release_channel' => 'canary', + 'default_update_window' => '01:00-02:00', + 'broker_url' => 'http://edge-broker.internal:4300', + 'public_broker_url' => 'https://broker.example.test/edge-broker', + 'broker_auth_mode' => 'manager', + ], $session['headers']); + + $update + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $config = api_client()->get('/edgegateway/config', $session['headers']); + + $config + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $values = []; + foreach ($config->data() as $entry) { + $values[(string)$entry['variable']] = $entry['value']; + } + + expect($values) + ->toHaveKey('default_release_channel', 'canary') + ->toHaveKey('broker_url', 'http://edge-broker.internal:4300') + ->toHaveKey('public_broker_url', 'https://broker.example.test/edge-broker') + ->toHaveKey('broker_auth_mode', 'manager') + ->toHaveKey('broker_shared_secret', ''); + + $presence = api_client()->post( + '/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/presence', + [ + 'status' => 'connected', + 'connection_id' => 'module-config-broker-presence', + ], + edge_test_broker_headers(['X-Edge-Broker-Secret' => edge_test_broker_secret()]) + ); + + $presence + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $shellSession = api_client()->post( + '/edge-gateways/' . (int)$gateway['id'] . '/shell-sessions', + ['reason' => 'Config broker URL validation'], + $session['headers'] + ); + + $shellSession + ->assertStatus(201) + ->assertEnvelope() + ->assertSuccess(); + + expect($shellSession->data()) + ->toHaveKey('broker_url', 'https://broker.example.test/edge-broker') + ->toHaveKey('ws_url', 'wss://broker.example.test/edge-broker/ws/browser-shell') + ->and($shellSession->data()['diagnostics']['public_broker_url_configured'] ?? null) + ->toBeTrue() + ->and($shellSession->data()['diagnostics']['broker_auth_mode'] ?? null) + ->toBe('manager'); +}); + +it('returns broker diagnostics for the current edge gateway module config values', function (): void { + $department = api_fixtures()->createDepartment([ + 'name' => 'Edge Gateway Diagnostics Department', + ]); + $session = api_fixtures()->createEdgeOperatorSession((int)$department['id']); + + api_fixtures()->setModuleConfig('edgegateway', 'enabled', 'true', 'bool'); + api_fixtures()->setModuleConfig('edgegateway', 'broker_url', 'http://127.0.0.1:1', 'string'); + api_fixtures()->setModuleConfig('edgegateway', 'public_broker_url', 'http://127.0.0.1:1/edge-broker', 'string'); + api_fixtures()->setModuleConfig('edgegateway', 'broker_auth_mode', 'manager', 'string'); + api_fixtures()->setModuleConfig('edgegateway', 'broker_shared_secret', 'diagnostic-secret', 'string'); + + $response = api_client()->post('/edgegateway/config/broker-diagnostics', [ + 'target' => 'all', + 'broker_url' => 'http://127.0.0.1:1', + 'public_broker_url' => 'http://127.0.0.1:1/edge-broker', + 'broker_auth_mode' => 'manager', + 'broker_shared_secret' => 'diagnostic-secret', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()) + ->toHaveKey('internal_broker_connection') + ->toHaveKey('public_broker_url') + ->toHaveKey('broker_shared_secret') + ->toHaveKey('broker_auth_mode', 'manager') + ->toHaveKey('broker_shared_secret_configured', true) + ->and($response->data()['internal_broker_connection']['ok'] ?? null) + ->toBeFalse() + ->and($response->data()['public_broker_url']['ok'] ?? null) + ->toBeFalse() + ->and($response->data()['broker_shared_secret']['ok'] ?? null) + ->toBeFalse(); +}); diff --git a/services/nginx/app/tests/Api/OrderBookingsCompletionApiTest.php b/services/nginx/app/tests/Api/OrderBookingsCompletionApiTest.php new file mode 100644 index 00000000..bf7fb24b --- /dev/null +++ b/services/nginx/app/tests/Api/OrderBookingsCompletionApiTest.php @@ -0,0 +1,132 @@ +createUser(['display_name' => 'Standalone Booking Customer']); + $department = api_fixtures()->createDepartment(); + $booking = api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'order_id' => null, + ]); + $session = api_fixtures()->createUserSession([ + 'complete_bookings', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->post('/order-bookings/complete', [ + 'id' => $booking['id'], + ], $session['headers']); + + $response + ->assertStatus(409) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Booking completion must be completed through POS desktop or mobile steps.'); + + $result = api_test_runtime()->db() + ->query('SELECT order_id FROM order_bookings WHERE id = ' . (int)$booking['id'] . ' LIMIT 1'); + + expect($result)->not->toBeFalse(); + $row = $result->fetch_assoc(); + expect($row)->toBeArray(); + expect($row['order_id'] ?? null)->toBeNull(); + + $result = api_test_runtime()->db() + ->query('SELECT COUNT(*) AS total FROM orders WHERE customer_id = ' . (int)$customer['customer_number']); + + expect($result)->not->toBeFalse(); + $row = $result->fetch_assoc(); + expect((int)($row['total'] ?? -1))->toBe(0); +}); + +it('allows linked POS order booking completion for mobile POS compatibility', function (): void { + $customer = api_fixtures()->createUser(['display_name' => 'Linked Booking Customer']); + $department = api_fixtures()->createDepartment(); + $cashier = api_fixtures()->createUser(['display_name' => 'Linked Booking Cashier']); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'safety_seal' => null, + ]); + $booking = api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'order_id' => $order['id'], + ]); + $session = api_fixtures()->createUserSession([ + 'complete_bookings', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->post('/order-bookings/complete', [ + 'id' => $booking['id'], + 'safety_seal' => 123456, + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()['id'] ?? null)->toBe($booking['id']); + expect($response->data()['order_id'] ?? null)->toBe($order['id']); + + $result = api_test_runtime()->db() + ->query('SELECT order_id FROM order_bookings WHERE id = ' . (int)$booking['id'] . ' LIMIT 1'); + + expect($result)->not->toBeFalse(); + $row = $result->fetch_assoc(); + expect($row)->toBeArray(); + expect((int)($row['order_id'] ?? 0))->toBe($order['id']); +}); + +it('accepts numeric safety seal strings when completing a linked POS order booking', function (): void { + $customer = api_fixtures()->createUser(['display_name' => 'Linked Booking String Seal Customer']); + $department = api_fixtures()->createDepartment(); + $cashier = api_fixtures()->createUser(['display_name' => 'Linked Booking String Seal Cashier']); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'safety_seal' => null, + ]); + $booking = api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'order_id' => $order['id'], + ]); + $session = api_fixtures()->createUserSession([ + 'complete_bookings', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->post('/order-bookings/complete', [ + 'id' => $booking['id'], + 'safety_seal' => '123456', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()['id'] ?? null)->toBe($booking['id']); + expect($response->data()['order_id'] ?? null)->toBe($order['id']); +}); + +it('disables the legacy complete wash without certificate route', function (): void { + $response = api_client()->post('/admin/bookings/completeWashWithoutWashCertificate', [ + 'id' => 123, + ]); + + $response + ->assertStatus(410) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Booking completion must be completed through POS desktop or mobile steps.'); +}); diff --git a/services/nginx/app/tests/Api/OrderBookingsUpdateApiTest.php b/services/nginx/app/tests/Api/OrderBookingsUpdateApiTest.php new file mode 100644 index 00000000..cdbbb90e --- /dev/null +++ b/services/nginx/app/tests/Api/OrderBookingsUpdateApiTest.php @@ -0,0 +1,108 @@ +createUser(['display_name' => 'Detached Booking Customer']); + $department = api_fixtures()->createDepartment(); + $cashier = api_fixtures()->createUser(['display_name' => 'Detached Booking Cashier']); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + ]); + $booking = api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'order_id' => $order['id'], + ]); + + api_test_runtime()->db()->query( + 'UPDATE orders SET booking_id = ' . (int)$booking['id'] . ' WHERE id = ' . (int)$order['id'] + ); + + $session = api_fixtures()->createUserSession([ + 'edit_bookings', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->put('/order-bookings', [ + 'id' => $booking['id'], + 'order_id' => null, + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()) + ->toHaveKey('order_id') + ->and($response->data()['order_id']) + ->toBeNull(); + + $result = api_test_runtime()->db()->query( + 'SELECT ob.order_id AS booking_order_id, o.booking_id AS order_booking_id ' . + 'FROM order_bookings ob JOIN orders o ON o.id = ' . (int)$order['id'] . ' ' . + 'WHERE ob.id = ' . (int)$booking['id'] . ' LIMIT 1' + ); + + expect($result)->not->toBeFalse(); + $row = $result->fetch_assoc(); + expect($row)->toBeArray(); + expect($row['booking_order_id'] ?? null)->toBeNull(); + expect($row['order_booking_id'] ?? null)->toBeNull(); +}); + +it('keeps the linked order when order_id is omitted from an order booking update', function (): void { + $customer = api_fixtures()->createUser(['display_name' => 'Still Linked Booking Customer']); + $department = api_fixtures()->createDepartment(); + $cashier = api_fixtures()->createUser(['display_name' => 'Still Linked Booking Cashier']); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + ]); + $booking = api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'order_id' => $order['id'], + 'reference' => 'ORIGINAL-BOOKING-REF', + ]); + + api_test_runtime()->db()->query( + 'UPDATE orders SET booking_id = ' . (int)$booking['id'] . ' WHERE id = ' . (int)$order['id'] + ); + + $session = api_fixtures()->createUserSession([ + 'edit_bookings', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->put('/order-bookings', [ + 'id' => $booking['id'], + 'reference' => 'UPDATED-BOOKING-REF', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect((int)($response->data()['order_id'] ?? 0))->toBe($order['id']); + expect($response->data()['reference'] ?? null)->toBe('UPDATED-BOOKING-REF'); + + $result = api_test_runtime()->db()->query( + 'SELECT ob.order_id AS booking_order_id, o.booking_id AS order_booking_id ' . + 'FROM order_bookings ob JOIN orders o ON o.id = ' . (int)$order['id'] . ' ' . + 'WHERE ob.id = ' . (int)$booking['id'] . ' LIMIT 1' + ); + + expect($result)->not->toBeFalse(); + $row = $result->fetch_assoc(); + expect($row)->toBeArray(); + expect((int)($row['booking_order_id'] ?? 0))->toBe($order['id']); + expect((int)($row['order_booking_id'] ?? 0))->toBe($booking['id']); +}); diff --git a/services/nginx/app/tests/Api/OrderItemsApiTest.php b/services/nginx/app/tests/Api/OrderItemsApiTest.php new file mode 100644 index 00000000..577c80f6 --- /dev/null +++ b/services/nginx/app/tests/Api/OrderItemsApiTest.php @@ -0,0 +1,113 @@ +createUser(['display_name' => 'Order Item Customer']); + $department = api_fixtures()->createDepartment(); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'NOTE-REQUIRED', + ]); + $product = api_fixtures()->createProduct([ + 'id' => 902701, + 'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME, + 'price' => 299, + 'requires_note' => 0, + ]); + $session = api_fixtures()->createUserSession([], ['group_id' => 1]); + + api_client() + ->post('/order/items', [ + 'order_id' => $order['id'], + 'product_id' => $product['id'], + 'quantity' => 1, + 'notes' => ' ', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Notes is required for this product'); + + $response = api_client()->post('/order/items', [ + 'order_id' => $order['id'], + 'product_id' => $product['id'], + 'quantity' => 1, + 'notes' => 'Graffiti removal on left side', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side'); +}); + +it('does not allow clearing notes for order items whose product requires notes', function (): void { + api_test_covers('PUT /order/items', 'validation'); + + $customer = api_fixtures()->createUser(['display_name' => 'Order Item Edit Customer']); + $department = api_fixtures()->createDepartment(); + $cashier = api_fixtures()->createUser(['display_name' => 'Order Item Cashier']); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'cashier_id' => $cashier['id'], + 'reference' => 'NOTE-EDIT', + ]); + $product = api_fixtures()->createProduct([ + 'id' => 902702, + 'name' => 'API Note Required Product', + 'price' => 199, + 'requires_note' => 1, + ]); + $orderItem = api_fixtures()->createOrderItem([ + 'order_id' => $order['id'], + 'product_id' => $product['id'], + 'cashier_id' => $cashier['id'], + 'price' => 199, + 'quantity' => 1, + 'notes' => 'Initial note', + ]); + $session = api_fixtures()->createUserSession(['edit_order_items']); + + api_client() + ->put('/order/items', [ + 'id' => $orderItem['id'], + 'price' => 199, + 'quantity' => 1, + 'reference' => '', + 'notes' => '', + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Notes is required for this product'); +}); + +it('returns the extraordinary chemistry product with requires_note enabled', function (): void { + api_test_covers('GET /products', 'happy'); + + $product = api_fixtures()->createProduct([ + 'id' => 902703, + 'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME, + 'price' => 299, + 'requires_note' => 0, + ]); + $session = api_fixtures()->createUserSession([], ['group_id' => 1]); + + $response = api_client()->get('/products?id=' . $product['id'], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()['requires_note'] ?? null)->toBeTrue(); +}); diff --git a/services/nginx/app/tests/Api/OrdersApiTest.php b/services/nginx/app/tests/Api/OrdersApiTest.php index 27bb0873..6e65d063 100644 --- a/services/nginx/app/tests/Api/OrdersApiTest.php +++ b/services/nginx/app/tests/Api/OrdersApiTest.php @@ -4,66 +4,33 @@ declare(strict_types=1); usesApiSuite(); -function activeWashCertificateAttachmentIdsForOrder(int $orderId): array -{ - $statement = api_test_runtime()->db()->prepare( - 'SELECT id, content - FROM object_attachments - WHERE object_type IN (?, ?) - AND object_id = ? - AND deleted_at IS NULL - ORDER BY id ASC' - ); - expect($statement)->not->toBeFalse(); - - $objectType = 'orders'; - $backtickedObjectType = '`orders`'; - $statement->bind_param('ssi', $objectType, $backtickedObjectType, $orderId); - $statement->execute(); - - $result = $statement->get_result(); - $attachmentIds = []; - while ($row = $result->fetch_assoc()) { - $content = json_decode((string)($row['content'] ?? ''), true); - $other = is_array($content) ? ($content['other'] ?? null) : null; - if (is_string($other) && strtolower($other) === 'wash_certificate') { - $attachmentIds[] = (int)($row['id'] ?? 0); - } - } - - $result->free(); - $statement->close(); - - return array_values(array_filter($attachmentIds, static fn(int $id): bool => $id > 0)); -} - -it('lists orders for an admin-scoped user and limits the results to the permitted departments', function (): void { +it('lists orders for an admin-scoped user and limits the results to permitted departments', function (): void { api_test_covers('GET /orders', 'happy'); - $departmentOne = api_fixtures()->createDepartment(['name' => 'Department One']); - $departmentTwo = api_fixtures()->createDepartment(['name' => 'Department Two']); - $customerOne = api_fixtures()->createUser(['display_name' => 'Customer One']); - $customerTwo = api_fixtures()->createUser(['display_name' => 'Customer Two']); - $cashier = api_fixtures()->createUser(['display_name' => 'Cashier']); + $visibleDepartment = api_fixtures()->createDepartment(['name' => 'Orders List Visible']); + $hiddenDepartment = api_fixtures()->createDepartment(['name' => 'Orders List Hidden']); + $visibleCustomer = api_fixtures()->createUser(['display_name' => 'Orders List Visible Customer']); + $hiddenCustomer = api_fixtures()->createUser(['display_name' => 'Orders List Hidden Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Orders List Cashier']); $visibleOrder = api_fixtures()->createOrder([ - 'customer_id' => $customerOne['customer_number'], + 'customer_id' => $visibleCustomer['customer_number'], 'cashier_id' => $cashier['id'], - 'department_id' => $departmentOne['id'], - 'reference' => 'VISIBLE', + 'department_id' => $visibleDepartment['id'], + 'reference' => 'VISIBLE-ORDER', 'reg_1' => 'VISIBLE1', ]); $hiddenOrder = api_fixtures()->createOrder([ - 'customer_id' => $customerTwo['customer_number'], + 'customer_id' => $hiddenCustomer['customer_number'], 'cashier_id' => $cashier['id'], - 'department_id' => $departmentTwo['id'], - 'reference' => 'HIDDEN', + 'department_id' => $hiddenDepartment['id'], + 'reference' => 'HIDDEN-ORDER', 'reg_1' => 'HIDDEN1', ]); $session = api_fixtures()->createUserSession([ 'list_orders', - 'department_access_' . $departmentOne['id'], + 'department_access_' . $visibleDepartment['id'], ]); $response = api_client()->get('/orders', $session['headers']); @@ -83,93 +50,38 @@ it('lists orders for an admin-scoped user and limits the results to the permitte ->not->toContain($hiddenOrder['id']); }); -it('lists only the targeted customer orders for subuser sessions', function (): void { - api_test_covers('GET /orders', 'happy'); - - $department = api_fixtures()->createDepartment(); - $targetCustomer = api_fixtures()->createUser(['display_name' => 'Target Customer']); - $otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Customer']); - $cashier = api_fixtures()->createUser(['display_name' => 'Subuser Cashier']); - - $targetOrder = api_fixtures()->createOrder([ - 'customer_id' => $targetCustomer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'TARGET', - 'reg_1' => 'TARGET1', - ]); - $otherOrder = api_fixtures()->createOrder([ - 'customer_id' => $otherCustomer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'OTHER', - 'reg_1' => 'OTHER1', - ]); - - $session = api_fixtures()->createSubuserSession( - (int)$targetCustomer['customer_number'], - ['ORDERS_LIST'] - ); - - $response = api_client()->get('/orders', $session['headers']); - - $response - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess(); - - $orderIds = array_map( - static fn(array $order): int => (int)($order['id'] ?? 0), - is_array($response->data()) ? $response->data() : [] - ); - - expect($orderIds) - ->toContain($targetOrder['id']) - ->not->toContain($otherOrder['id']); - - expect($response->meta())->toHaveKey('target_customer_number', $targetCustomer['customer_number']); -}); - -it('returns the current auth and permission failures when order listing is not allowed', function (): void { +it('returns auth and permission failures when order listing is not allowed', function (): void { api_test_covers('GET /orders', 'auth'); - $missingToken = api_client()->get('/orders'); - - $missingToken + api_client()->get('/orders') ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Invalid session'); $session = api_fixtures()->createUserSession([]); - $missingPermissions = api_client()->get('/orders', $session['headers']); - $missingPermissions + api_client()->get('/orders', $session['headers']) ->assertStatus(403) ->assertEnvelope() ->assertSuccess(false) ->assertMissingPermissions(['list_own_orders', 'list_orders']); }); -it('creates orders through the real endpoint', function (): void { +it('creates orders through the orders endpoint', function (): void { api_test_covers('POST /orders', 'happy'); - $customer = api_fixtures()->createUser(['display_name' => 'Order Customer']); - $department = api_fixtures()->createDepartment(); - api_fixtures()->createInvoiceCollection([ - 'customer_number' => $customer['customer_number'], - ]); + $customer = api_fixtures()->createUser(['display_name' => 'Order Create Customer']); + $department = api_fixtures()->createDepartment(['name' => 'Order Create Department']); $session = api_fixtures()->createUserSession(['add_order']); $response = api_client()->post('/orders', [ 'customer_id' => $customer['customer_number'], 'department_id' => $department['id'], - 'reference' => 'ORDER-POST', + 'reference' => 'ORDER-CREATE', 'notes' => 'Created through HTTP', + 'reg_1' => ' create-123 ', 'safety_seal' => 'SEAL-CREATE', - 'reg_1' => ' post-123 ', - 'reg_2' => ' tr 9-8 ', - 'reg_3' => ' 7z/x ', ], $session['headers']); $response @@ -183,9 +95,8 @@ it('creates orders through the real endpoint', function (): void { $row = api_fixtures()->fetchRowById('orders', $orderId); expect($row)->not->toBeNull(); - expect($row['reg_1'] ?? null)->toBe('POST123'); - expect($row['reg_2'] ?? null)->toBe('TR98'); - expect($row['reg_3'] ?? null)->toBe('7ZX'); + expect($row['reference'] ?? null)->toBe('ORDER-CREATE'); + expect($row['reg_1'] ?? null)->toBe('CREATE123'); expect($row['safety_seal'] ?? null)->toBe('SEAL-CREATE'); api_fixtures()->cleanupDeleteById('orders', $orderId); @@ -198,581 +109,88 @@ it('rejects invalid order creation requests', function (): void { $department = api_fixtures()->createDepartment(); $session = api_fixtures()->createUserSession(['add_order']); - $missingReference = api_client()->post('/orders', [ + api_client()->post('/orders', [ 'customer_id' => $customer['customer_number'], 'department_id' => $department['id'], 'notes' => 'Missing reference', 'reg_1' => 'MISSREF', - ], $session['headers']); - - $missingReference + ], $session['headers']) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Reference is required'); - - $invalidDepartment = api_client()->post('/orders', [ - 'customer_id' => $customer['customer_number'], - 'department_id' => 999999, - 'reference' => 'BAD-DEPT', - 'notes' => 'Bad department', - 'reg_1' => 'BADDEPT', - ], $session['headers']); - - $invalidDepartment - ->assertStatus(500) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessageContains('getDepartmentById()'); - - $invalidCustomer = api_client()->post('/orders', [ - 'customer_id' => 999999, - 'department_id' => $department['id'], - 'reference' => 'BAD-CUSTOMER', - 'notes' => 'Bad customer', - 'reg_1' => 'BADCUST', - ], $session['headers']); - - $invalidCustomer - ->assertStatus(500) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessageContains('e-conomic request failed with HTTP'); - - api_fixtures()->addCustomerAttribute((int)$customer['id'], 'requiresReferenceNumber'); - $missingRequiredReference = api_client()->post('/orders', [ - 'customer_id' => $customer['customer_number'], - 'department_id' => $department['id'], - 'reference' => '', - 'notes' => 'Empty reference', - 'reg_1' => 'REQREF1', - ], $session['headers']); - - $missingRequiredReference - ->assertStatus(400) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessage('Reference is required by the customer'); }); -it('updates orders through the primary endpoint', function (): void { +it('updates orders through the primary and legacy endpoints', function (): void { api_test_covers('PUT /orders', 'happy'); - - $department = api_fixtures()->createDepartment(['name' => 'Original Department']); - $updatedDepartment = api_fixtures()->createDepartment(['name' => 'Updated Department']); - $customer = api_fixtures()->createUser(['display_name' => 'Original Customer']); - $updatedCustomer = api_fixtures()->createUser(['display_name' => 'Updated Customer']); - $cashier = api_fixtures()->createUser(['display_name' => 'Order Editor']); - $updatedInvoiceCollection = api_fixtures()->createInvoiceCollection([ - 'customer_number' => $updatedCustomer['customer_number'], - ]); - $order = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'BEFORE-REF', - 'reg_1' => 'BEFR123', - 'reg_2' => 'OLD-2', - 'reg_3' => 'OLD-3', - 'notes' => 'Before update', - 'po' => 'PO-BEFORE', - 'lane' => 2, - 'wash_id' => 'WASH-BEFORE', - 'booking_id' => 321, - 'safety_seal' => 'SEAL-BEFORE', - 'include_in_invoice' => true, - 'created_at' => '2026-04-08 08:44:07', - ]); - $session = api_fixtures()->createUserSession(['edit_order']); - - $response = api_client()->put('/orders', [ - 'id' => $order['id'], - 'customer_id' => $updatedCustomer['customer_number'], - 'department_id' => $updatedDepartment['id'], - 'reference' => 'AFTER-REF', - 'reg_1' => ' af-tr 123 ', - 'reg_2' => ' new-2 ', - 'reg_3' => ' new/3 ', - 'notes' => 'After update', - 'po' => 'PO-123', - 'lane' => 7, - 'wash_id' => 'WASH-123', - 'booking_id' => 9876, - 'safety_seal' => 'SEAL-AFTER', - 'invoice_collection_id' => $updatedInvoiceCollection['id'], - 'created_at' => '2026-04-09 13:37:00', - 'include_in_invoice' => false, - ], $session['headers']); - - $response - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess() - ->assertMessage('Order updated successfully'); - - $row = api_fixtures()->fetchRowById('orders', (int)$order['id']); - - expect($row)->not->toBeNull(); - expect((int)($row['customer_id'] ?? 0))->toBe((int)$updatedCustomer['customer_number']); - expect((int)($row['department_id'] ?? 0))->toBe((int)$updatedDepartment['id']); - expect($row['reference'] ?? null)->toBe('AFTER-REF'); - expect($row['reg_1'] ?? null)->toBe('AFTR123'); - expect($row['reg_2'] ?? null)->toBe('NEW2'); - expect($row['reg_3'] ?? null)->toBe('NEW3'); - expect($row['notes'] ?? null)->toBe('After update'); - expect($row['po'] ?? null)->toBe('PO-123'); - expect((int)($row['lane'] ?? 0))->toBe(7); - expect($row['wash_id'] ?? null)->toBe('WASH-123'); - expect((int)($row['booking_id'] ?? 0))->toBe(9876); - expect($row['safety_seal'] ?? null)->toBe('SEAL-AFTER'); - expect((int)($row['invoice_collection_id'] ?? 0))->toBe((int)$updatedInvoiceCollection['id']); - expect($row['created_at'] ?? null)->toBe('2026-04-09 13:37:00'); - expect((int)($row['include_in_invoice'] ?? 1))->toBe(0); -}); - -it('reassigns invoice collections when changing an order across the draft customer boundary', function (): void { - api_test_covers('PUT /orders', 'happy'); - - $draftCustomer = api_fixtures()->createUser(['display_name' => 'Draft Customer']); - $regularCustomer = api_fixtures()->createUser(['display_name' => 'Regular Customer']); - $department = api_fixtures()->createDepartment(['name' => 'Draft Boundary Department']); - $cashier = api_fixtures()->createUser(['display_name' => 'Draft Boundary Cashier']); - - api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int'); - - $order = api_fixtures()->createOrder([ - 'customer_id' => $regularCustomer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'DRAFT-BOUNDARY', - 'reg_1' => 'DRAFT123', - ]); - $session = api_fixtures()->createUserSession(['edit_order']); - - api_client()->put('/orders', [ - 'id' => $order['id'], - 'customer_id' => $draftCustomer['customer_number'], - ], $session['headers']) - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess() - ->assertMessage('Order updated successfully'); - - $draftRow = api_fixtures()->fetchRowById('orders', (int)$order['id']); - expect($draftRow)->not->toBeNull(); - $draftInvoiceCollectionId = (int)($draftRow['invoice_collection_id'] ?? 0); - expect($draftInvoiceCollectionId)->toBeGreaterThan(0); - - $draftCollectionRow = api_fixtures()->fetchRowById('collected_order_invoices', $draftInvoiceCollectionId); - expect($draftCollectionRow)->not->toBeNull(); - expect((int)($draftCollectionRow['customer_number'] ?? 0))->toBe((int)$draftCustomer['customer_number']); - - api_client()->put('/orders', [ - 'id' => $order['id'], - 'customer_id' => $regularCustomer['customer_number'], - ], $session['headers']) - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess() - ->assertMessage('Order updated successfully'); - - $regularRow = api_fixtures()->fetchRowById('orders', (int)$order['id']); - expect($regularRow)->not->toBeNull(); - $regularInvoiceCollectionId = (int)($regularRow['invoice_collection_id'] ?? 0); - expect($regularInvoiceCollectionId)->toBeGreaterThan(0); - - $regularCollectionRow = api_fixtures()->fetchRowById('collected_order_invoices', $regularInvoiceCollectionId); - expect($regularCollectionRow)->not->toBeNull(); - expect((int)($regularCollectionRow['customer_number'] ?? 0))->toBe((int)$regularCustomer['customer_number']); -}); - -it('regenerates attached wash certificates when certificate metadata changes through the primary endpoint', function (): void { - api_test_covers('PUT /orders', 'happy'); - - $department = api_fixtures()->createDepartment(['name' => 'Wash Certificate Department']); - $customer = api_fixtures()->createUser(['display_name' => 'Original Certificate Customer']); - $updatedCustomer = api_fixtures()->createUser(['display_name' => 'Updated Certificate Customer']); - $cashier = api_fixtures()->createUser(['display_name' => 'Certificate Cashier']); - $order = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'CERT-REF', - 'reg_1' => 'CERT123', - 'reg_2' => 'TRAIL1', - 'safety_seal' => 'SEAL-OLD', - 'created_at' => '2026-04-12 10:15:00', - ]); - $session = api_fixtures()->createUserSession([ - 'edit_order', - 'add_order_attachments', - 'department_access_' . $department['id'], - ]); - - api_client()->post('/orders/attachments/upload', [ - 'order_id' => $order['id'], - 'base64_file' => base64_encode('wash-certificate'), - 'file_name' => 'wash_certificate', - ], $session['headers']) - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess(); - - $initialAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']); - expect($initialAttachmentIds)->toHaveCount(1); - - $initialAttachmentId = $initialAttachmentIds[0]; - $initialAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $initialAttachmentId); - expect($initialAttachmentRow)->not->toBeNull(); - $initialDocument = (json_decode((string)($initialAttachmentRow['content'] ?? ''), true) ?? [])['document'] ?? null; - - api_client()->put('/orders', [ - 'id' => $order['id'], - 'customer_id' => $updatedCustomer['customer_number'], - 'reg_2' => ' new-trail-55 ', - 'safety_seal' => 'SEAL-NEW', - ], $session['headers']) - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess() - ->assertMessage('Order updated successfully'); - - $orderRow = api_fixtures()->fetchRowById('orders', (int)$order['id']); - expect($orderRow)->not->toBeNull(); - expect((int)($orderRow['customer_id'] ?? 0))->toBe((int)$updatedCustomer['customer_number']); - expect($orderRow['reg_2'] ?? null)->toBe('NEWTRAIL55'); - expect($orderRow['safety_seal'] ?? null)->toBe('SEAL-NEW'); - - $updatedAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']); - expect($updatedAttachmentIds)->toHaveCount(1); - expect($updatedAttachmentIds)->not->toContain($initialAttachmentId); - - $updatedAttachmentId = $updatedAttachmentIds[0]; - $updatedAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $updatedAttachmentId); - $deletedAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $initialAttachmentId); - expect($updatedAttachmentRow)->not->toBeNull(); - expect($deletedAttachmentRow)->not->toBeNull(); - expect($deletedAttachmentRow['deleted_at'] ?? null)->not->toBeNull(); - expect((json_decode((string)($updatedAttachmentRow['content'] ?? ''), true) ?? [])['document'] ?? null)->not->toBe($initialDocument); -}); - -it('regenerates attached wash certificates for legacy field-value updates through the alias endpoint', function (): void { api_test_covers('PUT /order', 'happy'); - $department = api_fixtures()->createDepartment(['name' => 'Alias Wash Certificate Department']); - $customer = api_fixtures()->createUser(['display_name' => 'Alias Certificate Customer']); - $cashier = api_fixtures()->createUser(['display_name' => 'Alias Certificate Cashier']); + $department = api_fixtures()->createDepartment(['name' => 'Order Update Department']); + $customer = api_fixtures()->createUser(['display_name' => 'Order Update Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Order Update Cashier']); $order = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'cashier_id' => $cashier['id'], 'department_id' => $department['id'], - 'reference' => 'ALIAS-CERT', - 'reg_1' => 'ALIAS123', - 'safety_seal' => 'ALIAS-SEAL', - ]); - $session = api_fixtures()->createUserSession([ - 'edit_order', - 'add_order_attachments', - 'department_access_' . $department['id'], + 'reference' => 'BEFORE-UPDATE', + 'notes' => 'Before update', + 'reg_1' => 'BEFORE1', ]); + $session = api_fixtures()->createUserSession(['edit_order']); - api_client()->post('/orders/attachments/upload', [ - 'order_id' => $order['id'], - 'base64_file' => base64_encode('wash-certificate'), - 'file_name' => 'wash_certificate', + api_client()->put('/orders', [ + 'id' => $order['id'], + 'reference' => 'AFTER-UPDATE', + 'notes' => 'After update', + 'reg_1' => ' after-123 ', ], $session['headers']) ->assertStatus(200) ->assertEnvelope() - ->assertSuccess(); - - $initialAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']); - expect($initialAttachmentIds)->toHaveCount(1); - - $initialAttachmentId = $initialAttachmentIds[0]; + ->assertSuccess() + ->assertMessage('Order updated successfully'); api_client()->put('/order', [ 'id' => $order['id'], - 'field' => 'reg_1', - 'value' => ' zz-88 11 ', + 'field' => 'reg_2', + 'value' => ' legacy-456 ', ], $session['headers']) ->assertStatus(200) ->assertEnvelope() ->assertSuccess() ->assertMessage('Order updated successfully'); - $orderRow = api_fixtures()->fetchRowById('orders', (int)$order['id']); - expect($orderRow)->not->toBeNull(); - expect($orderRow['reg_1'] ?? null)->toBe('ZZ8811'); - - $updatedAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']); - expect($updatedAttachmentIds)->toHaveCount(1); - expect($updatedAttachmentIds)->not->toContain($initialAttachmentId); - - $deletedAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $initialAttachmentId); - - expect($deletedAttachmentRow)->not->toBeNull(); - expect($deletedAttachmentRow['deleted_at'] ?? null)->not->toBeNull(); -}); - -it('supports legacy field-value metadata updates through the primary endpoint', function (): void { - api_test_covers('PUT /orders', 'happy'); - - $department = api_fixtures()->createDepartment(['name' => 'Legacy Field Department']); - $customer = api_fixtures()->createUser(['display_name' => 'Legacy Field Customer']); - $cashier = api_fixtures()->createUser(['display_name' => 'Legacy Field Cashier']); - $order = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'START-REF', - 'reg_1' => 'START123', - 'reg_2' => 'START2', - 'reg_3' => 'START3', - 'notes' => 'Start note', - ]); - $session = api_fixtures()->createUserSession(['edit_order']); - - $payloads = [ - ['field' => 'reference', 'value' => 'LEGACY-REF'], - ['field' => 'notes', 'value' => 'Legacy note'], - ['field' => 'safety_seal', 'value' => 'LEGACY-SEAL'], - ['field' => 'reg_1', 'value' => ' ab-12 34 '], - ['field' => 'reg_2', 'value' => ' cd/56 78 '], - ['field' => 'reg_3', 'value' => ' ef_90 12 '], - ]; - - foreach ($payloads as $payload) { - api_client()->put('/orders', [ - 'id' => $order['id'], - ...$payload, - ], $session['headers']) - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess() - ->assertMessage('Order updated successfully'); - } - $row = api_fixtures()->fetchRowById('orders', (int)$order['id']); expect($row)->not->toBeNull(); - expect($row['reference'] ?? null)->toBe('LEGACY-REF'); - expect($row['notes'] ?? null)->toBe('Legacy note'); - expect($row['safety_seal'] ?? null)->toBe('LEGACY-SEAL'); - expect($row['reg_1'] ?? null)->toBe('AB1234'); - expect($row['reg_2'] ?? null)->toBe('CD5678'); - expect($row['reg_3'] ?? null)->toBe('EF9012'); + expect($row['reference'] ?? null)->toBe('AFTER-UPDATE'); + expect($row['notes'] ?? null)->toBe('After update'); + expect($row['reg_1'] ?? null)->toBe('AFTER123'); + expect($row['reg_2'] ?? null)->toBe('LEGACY456'); }); -it('rejects invalid updates through the primary order endpoint', function (): void { +it('rejects invalid order update requests', function (): void { api_test_covers('PUT /orders', 'failure'); - - $session = api_fixtures()->createUserSession(['edit_order']); - - $missingId = api_client()->put('/orders', [ - 'notes' => 'No id', - ], $session['headers']); - - $missingId - ->assertStatus(400) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessage('ID is required'); - - $missingOrder = api_client()->put('/orders', [ - 'id' => 999999, - 'notes' => 'Missing order', - ], $session['headers']); - - $missingOrder - ->assertStatus(500) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessageContains('must not be accessed before initialization'); -}); - -it('updates orders through the legacy alias endpoint', function (): void { - api_test_covers('PUT /order', 'happy'); - - $department = api_fixtures()->createDepartment(['name' => 'Legacy Department']); - $updatedDepartment = api_fixtures()->createDepartment(['name' => 'Legacy Updated Department']); - $customer = api_fixtures()->createUser(['display_name' => 'Legacy Original Customer']); - $updatedCustomer = api_fixtures()->createUser(['display_name' => 'Legacy Updated Customer']); - $cashier = api_fixtures()->createUser(['display_name' => 'Legacy Editor']); - $updatedInvoiceCollection = api_fixtures()->createInvoiceCollection([ - 'customer_number' => $updatedCustomer['customer_number'], - ]); - $order = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'LEGACY-BEFORE', - 'reg_1' => 'LGCY123', - 'reg_2' => 'LGCY-2', - 'reg_3' => 'LGCY-3', - 'notes' => 'Legacy before', - 'po' => 'LEGACY-PO', - 'lane' => 4, - 'wash_id' => 'LEGACY-WASH', - 'booking_id' => 654, - 'safety_seal' => 'LEGACY-SEAL-BEFORE', - 'include_in_invoice' => false, - 'created_at' => '2026-04-10 08:15:00', - ]); - $session = api_fixtures()->createUserSession(['edit_order']); - - $response = api_client()->put('/order', [ - 'id' => $order['id'], - 'customer_id' => $updatedCustomer['customer_number'], - 'department_id' => $updatedDepartment['id'], - 'reference' => 'LEGACY-AFTER', - 'reg_1' => ' lgcy-999 ', - 'reg_2' => ' leg-2 ', - 'reg_3' => ' leg/3 ', - 'notes' => 'Legacy after', - 'po' => 'LEGACY-PO-NEW', - 'lane' => 9, - 'wash_id' => 'LEGACY-WASH-NEW', - 'booking_id' => 7654, - 'safety_seal' => 'LEGACY-SEAL-AFTER', - 'invoice_collection_id' => $updatedInvoiceCollection['id'], - 'created_at' => '2026-04-11 11:22:33', - 'include_in_invoice' => true, - ], $session['headers']); - - $response - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess() - ->assertMessage('Order updated successfully'); - - $row = api_fixtures()->fetchRowById('orders', (int)$order['id']); - - expect($row)->not->toBeNull(); - expect((int)($row['customer_id'] ?? 0))->toBe((int)$updatedCustomer['customer_number']); - expect((int)($row['department_id'] ?? 0))->toBe((int)$updatedDepartment['id']); - expect($row['reference'] ?? null)->toBe('LEGACY-AFTER'); - expect($row['reg_1'] ?? null)->toBe('LGCY999'); - expect($row['reg_2'] ?? null)->toBe('LEG2'); - expect($row['reg_3'] ?? null)->toBe('LEG3'); - expect($row['notes'] ?? null)->toBe('Legacy after'); - expect($row['po'] ?? null)->toBe('LEGACY-PO-NEW'); - expect((int)($row['lane'] ?? 0))->toBe(9); - expect($row['wash_id'] ?? null)->toBe('LEGACY-WASH-NEW'); - expect((int)($row['booking_id'] ?? 0))->toBe(7654); - expect($row['safety_seal'] ?? null)->toBe('LEGACY-SEAL-AFTER'); - expect((int)($row['invoice_collection_id'] ?? 0))->toBe((int)$updatedInvoiceCollection['id']); - expect($row['created_at'] ?? null)->toBe('2026-04-11 11:22:33'); - expect((int)($row['include_in_invoice'] ?? 0))->toBe(1); -}); - -it('supports legacy field-value metadata updates through the alias endpoint', function (): void { - api_test_covers('PUT /order', 'happy'); - - $department = api_fixtures()->createDepartment(['name' => 'Alias Field Department']); - $customer = api_fixtures()->createUser(['display_name' => 'Alias Field Customer']); - $cashier = api_fixtures()->createUser(['display_name' => 'Alias Field Cashier']); - $order = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'ALIAS-START', - 'reg_1' => 'ALIAS123', - 'reg_2' => 'ALIAS2', - 'reg_3' => 'ALIAS3', - 'notes' => 'Alias note', - ]); - $session = api_fixtures()->createUserSession(['edit_order']); - - $payloads = [ - ['field' => 'reference', 'value' => 'ALIAS-REF'], - ['field' => 'notes', 'value' => 'Alias updated note'], - ['field' => 'safety_seal', 'value' => 'ALIAS-SEAL'], - ['field' => 'reg_1', 'value' => ' gh-12 34 '], - ['field' => 'reg_2', 'value' => ' ij/56 78 '], - ['field' => 'reg_3', 'value' => ' kl_90 12 '], - ]; - - foreach ($payloads as $payload) { - api_client()->put('/order', [ - 'id' => $order['id'], - ...$payload, - ], $session['headers']) - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess() - ->assertMessage('Order updated successfully'); - } - - $row = api_fixtures()->fetchRowById('orders', (int)$order['id']); - - expect($row)->not->toBeNull(); - expect($row['reference'] ?? null)->toBe('ALIAS-REF'); - expect($row['notes'] ?? null)->toBe('Alias updated note'); - expect($row['safety_seal'] ?? null)->toBe('ALIAS-SEAL'); - expect($row['reg_1'] ?? null)->toBe('GH1234'); - expect($row['reg_2'] ?? null)->toBe('IJ5678'); - expect($row['reg_3'] ?? null)->toBe('KL9012'); -}); - -it('rejects invalid updates through the legacy alias endpoint', function (): void { api_test_covers('PUT /order', 'failure'); $session = api_fixtures()->createUserSession(['edit_order']); - $response = api_client()->put('/order', [ - 'id' => 999999, - 'notes' => 'Missing alias order', - ], $session['headers']); - - $response - ->assertStatus(500) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessageContains('must not be accessed before initialization'); -}); - -it('rejects unsupported legacy field-value updates on both update endpoints', function (): void { - api_test_covers('PUT /orders', 'failure'); - - $department = api_fixtures()->createDepartment(['name' => 'Unsupported Field Department']); - $customer = api_fixtures()->createUser(['display_name' => 'Unsupported Field Customer']); - $cashier = api_fixtures()->createUser(['display_name' => 'Unsupported Field Cashier']); - $order = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'cashier_id' => $cashier['id'], - 'department_id' => $department['id'], - 'reference' => 'UNCHANGED-REF', - 'notes' => 'Unchanged note', - 'reg_1' => 'UNCH123', - ]); - $session = api_fixtures()->createUserSession(['edit_order']); - foreach (['/orders', '/order'] as $endpoint) { api_client()->put($endpoint, [ - 'id' => $order['id'], - 'field' => 'cashier_id', - 'value' => 999999, + 'notes' => 'Missing id', ], $session['headers']) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) - ->assertMessage('Unsupported legacy order field: cashier_id'); + ->assertMessage('ID is required'); } - - $row = api_fixtures()->fetchRowById('orders', (int)$order['id']); - - expect($row)->not->toBeNull(); - expect($row['reference'] ?? null)->toBe('UNCHANGED-REF'); - expect($row['notes'] ?? null)->toBe('Unchanged note'); - expect($row['reg_1'] ?? null)->toBe('UNCH123'); }); -it('deletes orders through the real endpoint', function (): void { +it('deletes orders through the orders endpoint', function (): void { api_test_covers('DELETE /orders', 'happy'); - $department = api_fixtures()->createDepartment(); - $customer = api_fixtures()->createUser(); - $cashier = api_fixtures()->createUser(['display_name' => 'Delete Cashier']); + $department = api_fixtures()->createDepartment(['name' => 'Order Delete Department']); + $customer = api_fixtures()->createUser(['display_name' => 'Order Delete Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Order Delete Cashier']); $order = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'cashier_id' => $cashier['id'], @@ -783,11 +201,9 @@ it('deletes orders through the real endpoint', function (): void { 'department_access_' . $department['id'], ]); - $response = api_client()->delete('/orders', [ + api_client()->delete('/orders', [ 'id' => $order['id'], - ], $session['headers']); - - $response + ], $session['headers']) ->assertStatus(200) ->assertEnvelope() ->assertSuccess() @@ -801,27 +217,11 @@ it('deletes orders through the real endpoint', function (): void { it('rejects invalid order delete requests', function (): void { api_test_covers('DELETE /orders', 'failure'); - $department = api_fixtures()->createDepartment(); - $session = api_fixtures()->createUserSession([ - 'delete_order', - 'department_access_' . $department['id'], - ]); + $session = api_fixtures()->createUserSession(['delete_order']); - $missingId = api_client()->delete('/orders', [], $session['headers']); - - $missingId + api_client()->delete('/orders', [], $session['headers']) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('ID is required'); - - $missingOrder = api_client()->delete('/orders', [ - 'id' => 999999, - ], $session['headers']); - - $missingOrder - ->assertStatus(500) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessageContains('must not be accessed before initialization'); }); diff --git a/services/nginx/app/tests/Api/PingApiTest.php b/services/nginx/app/tests/Api/PingApiTest.php index 6d75a7a4..920e90cd 100644 --- a/services/nginx/app/tests/Api/PingApiTest.php +++ b/services/nginx/app/tests/Api/PingApiTest.php @@ -17,5 +17,7 @@ it('returns the ping contract', function (): void { expect($response->data()) ->toBeArray() ->toHaveKey('message', 'pong') - ->toHaveKey('time'); + ->toHaveKey('time') + ->toHaveKey('backend_version') + ->toHaveKey('api_commit_sha'); }); diff --git a/services/nginx/app/tests/Api/ReferenceSuggestionsApiTest.php b/services/nginx/app/tests/Api/ReferenceSuggestionsApiTest.php new file mode 100644 index 00000000..3ffd449e --- /dev/null +++ b/services/nginx/app/tests/Api/ReferenceSuggestionsApiTest.php @@ -0,0 +1,208 @@ +createDepartment(); + $customer = api_fixtures()->createUser(['display_name' => 'Reference Suggestion Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Reference Suggestion Cashier']); + + api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'reference' => 'REF-BOOKING', + 'datetime' => '2026-05-13 09:00:00', + 'created_at' => '2026-05-01 08:00:00', + 'reg_1' => 'BOOK1', + ]); + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'REF-HISTORY', + 'created_at' => '2026-05-10 10:00:00', + 'reg_1' => 'HIST1', + ]); + api_fixtures()->createVehicle([ + 'customer_id' => $customer['customer_number'], + 'type' => 53, + 'reg' => 'VEH1', + 'reference' => 'REF-VEHICLE', + 'created_at' => '2026-05-08 12:00:00', + ]); + + api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'reference' => 'REF-SHARED', + 'datetime' => '2026-05-15 11:00:00', + 'reg_1' => 'SHARED1', + ]); + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'REF-SHARED', + 'created_at' => '2026-05-12 10:00:00', + 'reg_1' => 'SHARED1', + ]); + api_fixtures()->createVehicle([ + 'customer_id' => $customer['customer_number'], + 'type' => 53, + 'reg' => 'SHARED1', + 'reference' => 'REF-SHARED', + 'created_at' => '2026-05-09 12:00:00', + ]); + + $deletedOrder = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'REF-DELETED', + ]); + api_test_runtime()->db() + ->query("UPDATE orders SET deleted_at = '2026-05-10 12:00:00' WHERE id = " . (int)$deletedOrder['id']); + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => '', + ]); + + $session = api_fixtures()->createUserSession([ + 'list_orders', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->get('/orders/reference-suggestions?' . http_build_query([ + 'search' => 'REF', + 'department_id' => $department['id'], + 'customer_id' => $customer['customer_number'], + 'reg_1' => 'SHARED1', + 'limit' => 10, + ]), $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $suggestions = $response->data(); + expect($suggestions)->toBeArray(); + + $booking = reference_suggestion_by_reference($suggestions, 'REF-BOOKING'); + $history = reference_suggestion_by_reference($suggestions, 'REF-HISTORY'); + $vehicle = reference_suggestion_by_reference($suggestions, 'REF-VEHICLE'); + $shared = reference_suggestion_by_reference($suggestions, 'REF-SHARED'); + + expect($booking['source'] ?? null)->toBe('booking'); + expect($booking['source_created_at'] ?? null)->toBe('2026-05-13 09:00:00'); + expect($history['source'] ?? null)->toBe('order'); + expect($vehicle['source'] ?? null)->toBe('vehicle'); + expect($shared['source'] ?? null)->toBe('booking'); + expect($shared['section'] ?? null)->toBe('this_vehicle'); + expect($booking['section'] ?? null)->toBe('other_customer_vehicle'); + expect($history['section'] ?? null)->toBe('other_customer_vehicle'); + expect($vehicle['section'] ?? null)->toBe('other_customer_vehicle'); + expect($shared['usage_count'] ?? null)->toBe(3); + expect($shared['last_used_at'] ?? null)->toBe('2026-05-15 11:00:00'); + expect(reference_suggestion_by_reference($suggestions, 'REF-DELETED'))->toBeNull(); + expect(reference_suggestion_by_reference($suggestions, ''))->toBeNull(); +}); + +it('orders reference suggestions by match relevance before context and frequency', function (): void { + $department = api_fixtures()->createDepartment(); + $customer = api_fixtures()->createUser(['display_name' => 'Reference Ranking Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Reference Ranking Cashier']); + + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'ABC', + 'created_at' => '2026-05-01 08:00:00', + 'reg_1' => 'OTHER1', + ]); + api_fixtures()->createOrderBooking([ + 'customer_number' => $customer['customer_number'], + 'department' => $department['id'], + 'reference' => 'ABC-PREFIX', + 'datetime' => '2026-05-16 08:00:00', + 'reg_1' => 'MATCH1', + ]); + api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'reference' => 'X-ABC-CONTAINS', + 'created_at' => '2026-05-17 08:00:00', + 'reg_1' => 'MATCH1', + ]); + + $session = api_fixtures()->createUserSession([ + 'list_orders', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->get('/orders/reference-suggestions?' . http_build_query([ + 'search' => 'ABC', + 'department_id' => $department['id'], + 'customer_id' => $customer['customer_number'], + 'reg_1' => 'MATCH1', + ]), $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $references = array_map( + static fn(array $suggestion): string => (string)($suggestion['reference'] ?? ''), + is_array($response->data()) ? $response->data() : [] + ); + + expect(array_slice($references, 0, 3))->toBe(['ABC', 'ABC-PREFIX', 'X-ABC-CONTAINS']); +}); + +it('enforces authentication, list permission, and department access for reference suggestions', function (): void { + api_test_covers('GET /orders/reference-suggestions', 'auth'); + + $department = api_fixtures()->createDepartment(); + + api_client()->get('/orders/reference-suggestions?department_id=' . $department['id']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Invalid session'); + + $missingListPermission = api_fixtures()->createUserSession([ + 'department_access_' . $department['id'], + ]); + api_client()->get('/orders/reference-suggestions?department_id=' . $department['id'], $missingListPermission['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['list_orders']); + + $missingDepartmentAccess = api_fixtures()->createUserSession(['list_orders']); + api_client()->get('/orders/reference-suggestions?department_id=' . $department['id'], $missingDepartmentAccess['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['department_access_' . $department['id']]); +}); diff --git a/services/nginx/app/tests/Api/SelfserveFixtureApiTest.php b/services/nginx/app/tests/Api/SelfserveFixtureApiTest.php new file mode 100644 index 00000000..127d7f4e --- /dev/null +++ b/services/nginx/app/tests/Api/SelfserveFixtureApiTest.php @@ -0,0 +1,106 @@ + getenv('REDIS_CONFIG_HOST') ?: getenv('REDIS_CONFIG_DEBUG_HOST') ?: 'redis', + 'user' => getenv('REDIS_CONFIG_USER') ?: getenv('REDIS_CONFIG_DEBUG_USER') ?: 'default', + 'database' => getenv('REDIS_CONFIG_DATABASE') ?: getenv('REDIS_CONFIG_DEBUG_DATABASE') ?: '0', + 'password' => getenv('REDIS_CONFIG_PASSWORD') ?: getenv('REDIS_CONFIG_DEBUG_PASSWORD') ?: '', + 'port' => getenv('REDIS_CONFIG_PORT') ?: getenv('REDIS_CONFIG_DEBUG_PORT') ?: '6379', + ]; + + define('redis', (new \classes\redis())->connect()); +} + +it('creates a comprehensive self-serve API scenario with demo relays', function (): void { + $scenario = api_fixtures()->createSelfServeScenario(); + + expect($scenario['relay_ids']['machine'])->toStartWith('demo-') + ->and($scenario['relay_ids']['entry'])->toStartWith('demo-') + ->and($scenario['lane']['relay_machine_id'])->toStartWith('demo-') + ->and($scenario['session']['status'])->toBe('MACHINE_STARTED') + ->and($scenario['session']['metadata_json']['relay_ids']['machine'])->toBe($scenario['relay_ids']['machine']) + ->and($scenario['tasks'])->toHaveCount(2) + ->and($scenario['events'])->toHaveCount(3); + + $lane = api_fixtures()->fetchRowById('department_lanes', (int)$scenario['lane']['id']); + $session = api_fixtures()->fetchRowById('selfserve_wash_sessions', (int)$scenario['session']['id']); + + expect($lane)->not->toBeNull() + ->and($lane['relay_machine_id'])->toBe($scenario['relay_ids']['machine']) + ->and($session)->not->toBeNull() + ->and($session['reg'])->toBe($scenario['vehicle']['reg']); +}); + +it('creates self-serve invoice orders on the draft customer with original customer and driver metadata attached', function (): void { + selfserve_fixture_ensure_legacy_redis_constant(); + + $draftCustomer = api_fixtures()->createUser(['display_name' => 'Self-Serve Draft Customer']); + $scenario = api_fixtures()->createSelfServeScenario(); + $subuser = api_fixtures()->createSubuser([ + 'name' => 'Self-Serve Driver', + 'username' => 'selfserve-driver-' . $scenario['vehicle']['reg'], + ]); + + api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int'); + api_fixtures()->setModuleConfig('selfserve', 'minute_product', (string)$scenario['product']['id'], 'int'); + + $lane = (new \classes\selfserve())->lane((int)$scenario['lane']['id']); + $lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED); + $lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH); + $lane->setLaneMode(\modules\selfserve\helpers\selfserve_lane_mode::MANUAL); + $lane->setCustomerNumber((int)$scenario['customer']['customer_number']); + $lane->setLicensePlate((string)$scenario['vehicle']['reg']); + $lane->setWashStartTime(time() - 620); + + $arguments = (new \modules\selfserve\classes\selfserve_lane_command_arguments()) + ->setCustomerNumber((int)$scenario['customer']['customer_number']) + ->setSubuserId((int)$subuser['id']); + + expect($lane->invoice($arguments))->toBeTrue(); + + $orderId = $lane->getLastInvoiceOrderId(); + expect($orderId)->toBeInt()->toBeGreaterThan(0); + + $order = api_fixtures()->fetchRowById('orders', $orderId); + $attachmentObjectType = '`orders`'; + $invoiceCollectionId = (int)($order['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + api_fixtures()->cleanupDeleteById('collected_order_invoices', $invoiceCollectionId); + } + api_fixtures()->cleanupDeleteById('orders', $orderId); + api_fixtures()->cleanupDeleteWhere('order_items', ['order_id' => $orderId]); + api_fixtures()->cleanupDeleteWhere('object_attachments', ['object_type' => $attachmentObjectType, 'object_id' => $orderId]); + + expect($order)->not->toBeNull() + ->and((int)$order['customer_id'])->toBe((int)$draftCustomer['customer_number']) + ->and((int)$order['department_id'])->toBe((int)$scenario['department']['id']) + ->and((string)$order['reg_1'])->toBe((string)$scenario['vehicle']['reg']) + ->and((int)$order['lane'])->toBe((int)$scenario['lane']['id']) + ->and($order['completed_at'])->toBeNull(); + + $db = api_test_runtime()->db(); + $result = $db->query( + "SELECT content FROM object_attachments WHERE object_type = '{$attachmentObjectType}' AND object_id = " . (int)$orderId . ' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1' + ); + $attachment = $result ? $result->fetch_assoc() : null; + $content = json_decode((string)($attachment['content'] ?? ''), true); + $metadata = is_array($content) ? ($content['other'] ?? null) : null; + + expect($metadata)->toBeArray() + ->and($metadata['type'] ?? null)->toBe(\attachments\helpers\attachment_content::OTHER_TYPE_SELF_SERVE_WASH) + ->and((int)($metadata['customer_number'] ?? 0))->toBe((int)$scenario['customer']['customer_number']) + ->and((int)($metadata['draft_customer_number'] ?? 0))->toBe((int)$draftCustomer['customer_number']) + ->and((int)($metadata['subuser_id'] ?? 0))->toBe((int)$subuser['id']) + ->and((int)($metadata['session_id'] ?? 0))->toBe((int)$scenario['session']['id']) + ->and($metadata['subuser']['name'] ?? null)->toBe('Self-Serve Driver'); +}); diff --git a/services/nginx/app/tests/Api/SelfserveLaneWashInProgressApiTest.php b/services/nginx/app/tests/Api/SelfserveLaneWashInProgressApiTest.php new file mode 100644 index 00000000..8e6cd284 --- /dev/null +++ b/services/nginx/app/tests/Api/SelfserveLaneWashInProgressApiTest.php @@ -0,0 +1,76 @@ +get('/modules/self-serve/lane/wash/in-progress?lane_id=1'); + + $response + ->assertStatus(401) + ->assertMessage('Authentication failed. Invalid or missing token.'); +}); + +it('reports both elevated and customer self-serve permissions when lane polling is not allowed', function (): void { + $session = api_fixtures()->createUserSession([]); + + $response = api_client()->get( + '/modules/self-serve/lane/wash/in-progress?lane_id=1', + $session['headers'] + ); + + $response + ->assertStatus(403) + ->assertMissingPermissions([ + 'modules_selfserve_lane_wash_in_progress_view', + 'list_own_department_selfserve_vehicle_conditions', + ]); +}); + +it('allows customer self-serve permission to view their own in-progress wash details', function (): void { + $group = api_fixtures()->createGroup([], [ + 'list_own_department_selfserve_vehicle_conditions', + ]); + $scenario = api_fixtures()->createSelfServeScenario([ + 'customer' => [ + 'group_id' => $group['id'], + ], + ]); + $token = api_fixtures()->createAuthToken((int)$scenario['customer']['id']); + + $response = api_client()->get( + '/modules/self-serve/lane/wash/in-progress?lane_id=' . (int)$scenario['lane']['id'], + api_fixtures()->bearerHeaders($token) + ); + + $response + ->assertStatus(200) + ->assertSuccess(true); + + expect($response->data()['in_progress'] ?? null)->toBeTrue() + ->and($response->data()['session']['customer_number'] ?? null)->toBe((int)$scenario['customer']['customer_number']) + ->and($response->data()['vehicle']['reg'] ?? null)->toBe($scenario['vehicle']['reg']); +}); + +it('redacts another customers in-progress wash from customer self-serve lane polling', function (): void { + $scenario = api_fixtures()->createSelfServeScenario(); + $otherSession = api_fixtures()->createUserSession([ + 'list_own_department_selfserve_vehicle_conditions', + ]); + + $response = api_client()->get( + '/modules/self-serve/lane/wash/in-progress?lane_id=' . (int)$scenario['lane']['id'], + $otherSession['headers'] + ); + + $response + ->assertStatus(200) + ->assertSuccess(true); + + expect($response->data())->toMatchArray([ + 'lane_id' => (int)$scenario['lane']['id'], + 'in_progress' => true, + 'session' => null, + 'customer' => null, + 'vehicle' => null, + ]); +}); diff --git a/services/nginx/app/tests/Api/SelfserveZZZShellyGuardApiTest.php b/services/nginx/app/tests/Api/SelfserveZZZShellyGuardApiTest.php new file mode 100644 index 00000000..5100c0de --- /dev/null +++ b/services/nginx/app/tests/Api/SelfserveZZZShellyGuardApiTest.php @@ -0,0 +1,7 @@ +toBe([]); +}); diff --git a/services/nginx/app/tests/Api/SubusersApiTest.php b/services/nginx/app/tests/Api/SubusersApiTest.php new file mode 100644 index 00000000..5535293a --- /dev/null +++ b/services/nginx/app/tests/Api/SubusersApiTest.php @@ -0,0 +1,176 @@ +createUserSession(['list_own_subusers']); + $subuser = api_fixtures()->createSubuser([ + 'name' => 'Legacy Permission Driver', + ]); + $grantId = api_fixtures()->grantSubuser( + $subuser['id'], + $session['user']['customer_number'], + ['VEHICLES_LIST'] + ); + + $legacyPermissions = '0'; + $statement = api_test_runtime()->db()->prepare( + 'UPDATE `subuser_grants` SET `permissions` = ? WHERE `id` = ?' + ); + $statement->bind_param('si', $legacyPermissions, $grantId); + $statement->execute(); + $statement->close(); + + $response = api_client()->get('/subusers?page=1&limit=5&include_non_enabled=true', $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $matchingSubusers = array_values(array_filter( + is_array($response->data()) ? $response->data() : [], + static fn (mixed $item): bool => is_array($item) && (int)($item['id'] ?? 0) === (int)$subuser['id'] + )); + + expect($matchingSubusers)->toHaveCount(1); + expect($matchingSubusers[0]['grant_permissions'] ?? null)->toBe([]); + expect($matchingSubusers[0]['permissions'] ?? null)->toBe([]); +}); + +it('uses the same password policy for subuser setup and password auth', function (): void { + api_test_covers('POST /subusers/setup', 'failure'); + api_test_covers('POST /subusers/auth/password', 'failure'); + + $subuser = api_fixtures()->createSubuser(); + + $setupResponse = api_client()->post('/subusers/setup', [ + 'token' => 'policy-test-token', + 'name' => 'Policy Driver', + 'password' => 'invalidpassword', + ]); + + $authResponse = api_client()->post('/subusers/auth/password', [ + 'subuser_id' => $subuser['id'], + 'password' => 'invalidpassword', + ]); + + $setupResponse + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(SUBUSER_PASSWORD_POLICY_MESSAGE); + + $authResponse + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(SUBUSER_PASSWORD_POLICY_MESSAGE); +}); + +it('lists chauffeur grants across customers for superusers', function (): void { + $session = api_fixtures()->createUserSession(['list_subusers']); + $firstCustomer = api_fixtures()->createUser([ + 'display_name' => 'Fleet Customer Alpha', + 'economic_customer_name' => 'Fleet Customer Alpha', + ]); + $secondCustomer = api_fixtures()->createUser([ + 'display_name' => 'Fleet Customer Beta', + 'economic_customer_name' => 'Fleet Customer Beta', + ]); + $firstSubuser = api_fixtures()->createSubuser(['name' => 'Alpha Driver']); + $secondSubuser = api_fixtures()->createSubuser(['name' => 'Beta Driver']); + $firstGrantId = api_fixtures()->grantSubuser( + (int)$firstSubuser['id'], + (int)$firstCustomer['customer_number'], + ['VEHICLES_LIST', 'SUBUSERS_LIST'] + ); + $secondGrantId = api_fixtures()->grantSubuser( + (int)$secondSubuser['id'], + (int)$secondCustomer['customer_number'], + ['BOOKINGS_LIST'] + ); + + $response = api_client()->get('/superuser/subusers?page=1&limit=20&search=Driver', $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $rows = array_values(array_filter( + is_array($response->data()) ? $response->data() : [], + static fn (mixed $item): bool => is_array($item) + && in_array((int)($item['grant_id'] ?? 0), [$firstGrantId, $secondGrantId], true) + )); + + expect($rows)->toHaveCount(2); + + $byGrantId = []; + foreach ($rows as $row) { + $byGrantId[(int)$row['grant_id']] = $row; + } + + expect($byGrantId[$firstGrantId]['customer_number'])->toBe((int)$firstCustomer['customer_number']); + expect($byGrantId[$firstGrantId]['customer_name'])->toBe('Fleet Customer Alpha'); + expect($byGrantId[$firstGrantId]['grant_permissions'])->toBe(['VEHICLES_LIST', 'SUBUSERS_LIST']); + expect($byGrantId[$secondGrantId]['customer_number'])->toBe((int)$secondCustomer['customer_number']); + expect($byGrantId[$secondGrantId]['customer_name'])->toBe('Fleet Customer Beta'); + expect($byGrantId[$secondGrantId]['grant_permissions'])->toBe(['BOOKINGS_LIST']); +}); + +it('lets superusers invite chauffeurs for a selected customer', function (): void { + $session = api_fixtures()->createUserSession(['add_subusers']); + $customer = api_fixtures()->createUser([ + 'display_name' => 'Invite Target Customer', + 'economic_customer_name' => 'Invite Target Customer', + ]); + $phone = 71000000 + ((int)$customer['customer_number'] % 1000000); + $createdSubuserId = null; + $createdGrantId = null; + $setupToken = null; + + try { + $response = api_client()->post('/superuser/subusers/invite', [ + 'customer_number' => (int)$customer['customer_number'], + 'name' => 'Invited Driver', + 'phone_country_code' => 45, + 'phone' => $phone, + 'permissions' => ['VEHICLES_LIST'], + 'note' => 'Created by superuser test', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $payload = $response->data(); + $createdSubuserId = isset($payload['subuser']['id']) ? (int)$payload['subuser']['id'] : null; + $createdGrantId = isset($payload['grant']['id']) ? (int)$payload['grant']['id'] : null; + $setupToken = isset($payload['invite']['setup_token']) ? (string)$payload['invite']['setup_token'] : null; + + expect($payload['subuser']['customer_number'] ?? null)->toBe((int)$customer['customer_number']); + expect($payload['subuser']['name'] ?? null)->toBe('Invited Driver'); + expect($payload['subuser']['grant_permissions'] ?? null)->toBe(['VEHICLES_LIST']); + expect($payload['grant']['note'] ?? null)->toBe('Created by superuser test'); + expect($payload['invite']['setup_link'] ?? null)->toBeString(); + } finally { + if ($setupToken !== null && $setupToken !== '') { + (new \objects\subusers_o())->invalidateSetupToken($setupToken); + } + if ($createdGrantId !== null) { + api_test_runtime()->db()->query('DELETE FROM `subuser_grants` WHERE `id` = ' . $createdGrantId); + } + if ($createdSubuserId !== null) { + api_test_runtime()->db()->query('DELETE FROM `tokens` WHERE `user_id` = ' . $createdSubuserId . " AND `type` = 'AUTH_TOKEN_SUBUSER'"); + api_test_runtime()->db()->query('DELETE FROM `subusers` WHERE `id` = ' . $createdSubuserId); + } + } +}); diff --git a/services/nginx/app/tests/Api/WorkerStatusApiTest.php b/services/nginx/app/tests/Api/WorkerStatusApiTest.php new file mode 100644 index 00000000..45a7f359 --- /dev/null +++ b/services/nginx/app/tests/Api/WorkerStatusApiTest.php @@ -0,0 +1,24 @@ +get('/worker/status'); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()) + ->toBeArray() + ->toHaveKey('api_commit_sha'); + + expect($response->data()['api_commit_sha']) + ->toBeString() + ->not->toBe(''); +}); diff --git a/services/nginx/app/tests/Api/api_coverage_manifest.php b/services/nginx/app/tests/Api/api_coverage_manifest.php index d39ffdce..f6ffdd09 100644 --- a/services/nginx/app/tests/Api/api_coverage_manifest.php +++ b/services/nginx/app/tests/Api/api_coverage_manifest.php @@ -16,11 +16,16 @@ return [ 'PUT /orders', 'PUT /numberplatescanners', 'DELETE /orders', + 'GET /branding', + 'POST /branding', + 'PUT /branding', + 'PUT /superuser/department/branding', 'POST /bird/voice/calls/webhook/inbound', ], 'manual_operations' => [ 'GET /ping', 'PUT /order', + 'GET /orders/reference-suggestions', ], 'happy_only_operations' => [ 'GET /ping', diff --git a/services/nginx/app/tests/Integration/EdgeGateway/EdgeGatewayBackendIntegrationTest.php b/services/nginx/app/tests/Integration/EdgeGateway/EdgeGatewayBackendIntegrationTest.php index 4056bef7..03672d7c 100644 --- a/services/nginx/app/tests/Integration/EdgeGateway/EdgeGatewayBackendIntegrationTest.php +++ b/services/nginx/app/tests/Integration/EdgeGateway/EdgeGatewayBackendIntegrationTest.php @@ -223,6 +223,24 @@ it('assembles tasks, logs, statistics, operations, commands, and shell lifecycle 'BROKER', ['source' => 'integration'] ); + $context['manager']->appendRelayTransportLog( + (int)$department['id'], + '/v2/devices/api/set/switch', + ['id' => 'M-7', 'on' => true, 'toggle_after' => 3], + [['id' => 'M-7', 'online' => true, 'on' => true]], + 'cloud', + null, + [ + 'module' => 'selfserve', + 'reason' => 'Integration relay start', + 'relay_name' => 'Roskilde Maskine', + 'relay_role' => 'MACHINE', + 'customer_number' => 700123, + 'actor' => [ + 'admin_user_id' => 42, + ], + ] + ); $context['manager']->recordTelemetryFromBroker($gatewayId, [ 'status' => 'ONLINE', @@ -252,8 +270,32 @@ it('assembles tasks, logs, statistics, operations, commands, and shell lifecycle expect(edge_gateway_integration_messages($logs['log_entries'] ?? [])) ->toContain('Integration log line'); + expect(edge_gateway_integration_messages($logs['relay_logs'] ?? [])) + ->toContain('MACHINE ON Roskilde Maskine handled by cloud via CLOUD'); + expect($logs['relay_logs'][0]['context']['module_responsible'] ?? null) + ->toBe('selfserve') + ->and($logs['relay_logs'][0]['context']['description'] ?? null) + ->toBe('MACHINE ON Roskilde Maskine handled by cloud via CLOUD') + ->and($logs['relay_logs'][0]['context']['relay_name'] ?? null) + ->toBe('Roskilde Maskine') + ->and($logs['relay_logs'][0]['context']['relay_role'] ?? null) + ->toBe('MACHINE') + ->and($logs['relay_logs'][0]['context']['associated']['customer_number'] ?? null) + ->toBe(700123) + ->and($logs['relay_logs'][0]['context']['associated']['admin_user_id'] ?? null) + ->toBe(42) + ->and($logs['relay_logs'][0]['context']['reason'] ?? null) + ->toBe('Integration relay start') + ->and($logs['relay_logs'][0]['context']['handler'] ?? null) + ->toBe('cloud') + ->and($logs['relay_logs'][0]['context']['signal']['request']['id'] ?? null) + ->toBe('M-7') + ->and($logs['relay_logs'][0]['context']['response']['on'] ?? null) + ->toBeTrue(); expect(edge_gateway_integration_messages($logs['timeline'] ?? [])) ->toContain('Integration discovery is executing.'); + expect(array_values(array_filter($logs['timeline'] ?? [], static fn(array $entry): bool => ($entry['type'] ?? null) === 'relay'))) + ->not->toBeEmpty(); expect($logs['shell_sessions'] ?? []) ->toBeArray() ->not->toBeEmpty() @@ -342,8 +384,7 @@ function edge_gateway_integration_context(): array } }; - $db = new db($dbConfig); - $db->connect(); + $db = edge_gateway_integration_wait_for_db($dbConfig); $GLOBALS['db'] = $db; $mysqli = $db->conn(); @@ -370,6 +411,25 @@ function edge_gateway_integration_context(): array ]; } +function edge_gateway_integration_wait_for_db(array $dbConfig): db +{ + $deadline = microtime(true) + 60; + $lastError = null; + + do { + try { + $db = new db($dbConfig); + $db->connect(); + return $db; + } catch (RuntimeException $exception) { + $lastError = $exception; + usleep(500000); + } + } while (microtime(true) < $deadline); + + throw $lastError ?? new RuntimeException('Database connection failed before a connection attempt completed.'); +} + /** * @return array{host:string,user:string,password:string,database:string,port:int} */ diff --git a/services/nginx/app/tests/Legacy/LegacyPhpScriptsTest.php b/services/nginx/app/tests/Legacy/LegacyPhpScriptsTest.php new file mode 100644 index 00000000..367a6af6 --- /dev/null +++ b/services/nginx/app/tests/Legacy/LegacyPhpScriptsTest.php @@ -0,0 +1,64 @@ + + */ +function legacy_test_manifest(): array +{ + return require app_path('tests/Support/legacy_test_manifest.php'); +} + +/** + * @param array{path:string, classification:string, type:string, bootstrap?:string} $entry + * @return array{exitCode:int, output:string} + */ +function run_legacy_manifest_entry(array $entry): array +{ + $path = app_path($entry['path']); + $php = escapeshellarg(PHP_BINARY); + + if ($entry['type'] === 'phpunit') { + $command = $php + . ' ' . escapeshellarg(app_path('vendor/bin/phpunit')) + . ' --bootstrap ' . escapeshellarg(app_path('tests/Support/legacy_bootstrap.php')) + . ' ' . escapeshellarg($path) + . ' 2>&1'; + } else { + $bootstrap = $entry['bootstrap'] ?? 'app'; + $command = $php + . ' ' . escapeshellarg(app_path('tests/Support/run_legacy_script.php')) + . ' ' . escapeshellarg($entry['path']) + . ' ' . escapeshellarg($bootstrap) + . ' 2>&1'; + } + + $lines = []; + $exitCode = 0; + exec($command, $lines, $exitCode); + + return [ + 'exitCode' => $exitCode, + 'output' => implode(PHP_EOL, $lines), + ]; +} + +foreach (legacy_test_manifest() as $entry) { + it('runs legacy PHP test ' . $entry['path'], function () use ($entry): void { + if ($entry['classification'] === 'manual-external') { + test()->markTestSkipped($entry['reason'] ?? 'Legacy test requires an external dependency.'); + } + + if (getenv('RUN_LEGACY_TESTS') !== '1') { + test()->markTestSkipped('Set RUN_LEGACY_TESTS=1 to run legacy PHP tests.'); + } + + $result = run_legacy_manifest_entry($entry); + + expect($result['exitCode'])->toBe( + 0, + 'Legacy test failed: ' . $entry['path'] . PHP_EOL . $result['output'] + ); + })->group('legacy', $entry['classification']); +} diff --git a/services/nginx/app/tests/Pest.php b/services/nginx/app/tests/Pest.php index 68289aec..3dbefd0d 100644 --- a/services/nginx/app/tests/Pest.php +++ b/services/nginx/app/tests/Pest.php @@ -7,3 +7,4 @@ use Tests\Support\Api\ApiTestCase; uses()->group('unit')->in('Unit'); uses()->group('integration')->in('Integration'); uses(ApiTestCase::class)->group('api')->in('Api'); +uses()->group('legacy')->in('Legacy'); diff --git a/services/nginx/app/tests/Support/Api/ApiFixtures.php b/services/nginx/app/tests/Support/Api/ApiFixtures.php index 0132642d..537b064a 100644 --- a/services/nginx/app/tests/Support/Api/ApiFixtures.php +++ b/services/nginx/app/tests/Support/Api/ApiFixtures.php @@ -61,6 +61,8 @@ final class ApiFixtures 'updated_at' => $attributes['updated_at'] ?? $now, ]); + $this->deleteRedisPattern('perm:user:' . $userId . ':*'); + $this->deleteRedisPattern('obj_prop:users:' . $userId . ':*'); $this->cleanup->add(function () use ($userId, $customerNumber): void { $this->purgeCustomerTraceData($userId, $customerNumber); $this->deleteRedisKey('user_id_from_customer_number_' . $customerNumber); @@ -70,6 +72,7 @@ final class ApiFixtures $this->deleteRedisKey('users_' . $userId . '_economic_customer'); $this->deleteRedisKey('`users`_' . $userId . '_economic_customer'); $this->deleteRedisPattern('perm:user:' . $userId . ':*'); + $this->deleteRedisPattern('obj_prop:users:' . $userId . ':*'); }); $economicName = (string)($attributes['economic_customer_name'] ?? $displayName); @@ -128,6 +131,7 @@ final class ApiFixtures 'dimension' => (int)($attributes['dimension'] ?? 0), 'branding' => (int)($attributes['branding'] ?? 0), 'visible' => (int)($attributes['visible'] ?? 1), + 'archived' => (int)($attributes['archived'] ?? 0), 'latitude' => $attributes['latitude'] ?? 0.0, 'longitude' => $attributes['longitude'] ?? 0.0, 'order_priority' => (int)($attributes['order_priority'] ?? 0), @@ -141,6 +145,32 @@ final class ApiFixtures return ['id' => $departmentId]; } + /** + * @param array $attributes + * @return array + */ + public function createBranding(array $attributes = []): array + { + $brandingId = $this->insertRow('branding', [ + 'name' => (string)($attributes['name'] ?? ('API Brand ' . $this->uniqueSuffix())), + 'description' => (string)($attributes['description'] ?? 'API branding'), + 'cvr' => (int)($attributes['cvr'] ?? 41004355), + 'address' => $attributes['address'] ?? null, + 'phone_country_code' => $attributes['phone_country_code'] ?? null, + 'phone' => $attributes['phone'] ?? null, + 'email' => $attributes['email'] ?? null, + 'website' => $attributes['website'] ?? null, + 'banner' => $attributes['banner'] ?? null, + 'logo' => $attributes['logo'] ?? null, + 'favicon' => $attributes['favicon'] ?? null, + 'signature' => $attributes['signature'] ?? null, + ]); + + $this->cleanup->add(fn() => $this->deleteById('branding', $brandingId)); + + return array_merge(['id' => $brandingId], $this->fetchRowById('branding', $brandingId) ?? []); + } + /** * @param array $attributes * @return array @@ -172,6 +202,324 @@ final class ApiFixtures return ['id' => $gateId, 'department' => $departmentId]; } + /** + * @param array $overrides + * @return array + */ + public function createSelfServeScenario(array $overrides = []): array + { + foreach ([ + 'department_lanes', + 'department_selfserve_conditions', + 'department_selfserve_condition_rules', + 'department_selfserve_questions', + 'department_selfserve_tasks', + 'department_selfserve_vehicle_conditions', + 'selfserve_machine_types', + 'selfserve_wash_sessions', + 'selfserve_wash_session_answers', + 'selfserve_wash_session_tasks', + 'selfserve_wash_session_events', + ] as $table) { + if (!$this->tableExists($table)) { + throw new RuntimeException('Self-serve API fixtures require table ' . $table . '.'); + } + } + + $suffix = strtolower($this->uniqueSuffix()); + $now = $this->now(); + $relayIds = array_merge([ + 'entry' => 'demo-selfserve-' . $suffix . '-entry', + 'exit' => 'demo-selfserve-' . $suffix . '-exit', + 'machine' => 'demo-selfserve-' . $suffix . '-machine', + 'program_picker' => 'demo-selfserve-' . $suffix . '-program-picker', + 'cleaner' => 'demo-selfserve-' . $suffix . '-cleaner', + ], is_array($overrides['relay_ids'] ?? null) ? $overrides['relay_ids'] : []); + + $department = $this->createDepartment(array_merge([ + 'name' => 'API Self-Serve Department ' . strtoupper($suffix), + 'description' => 'API self-serve fixture department', + 'visible' => 1, + 'latitude' => 55.6415, + 'longitude' => 12.0803, + ], is_array($overrides['department'] ?? null) ? $overrides['department'] : [])); + + $category = $this->createCategory([ + 'name' => 'API Self-Serve Category ' . strtoupper($suffix), + 'description' => 'API self-serve fixture category', + ]); + $this->linkDepartmentCategory((int)$department['id'], (int)$category['id']); + + $productData = array_merge([ + 'name' => 'API Self-Serve Wash ' . strtoupper($suffix), + 'description' => 'Comprehensive self-serve fixture wash', + 'price' => 100, + 'subscription_allowed' => 1, + 'category' => (int)$category['id'], + 'piktogram' => 'truck', + 'economic_product_id' => 0, + 'apply_category_discount' => 0, + 'requires_note' => 0, + 'is_wash' => 1, + 'display_in_booking_form' => 1, + 'order_priority' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], is_array($overrides['product'] ?? null) ? $overrides['product'] : []); + $productId = $this->insertRowWithExistingColumns('products', $productData); + $this->cleanup->add(fn() => $this->deleteById('products', $productId)); + + $machineTypeId = $this->insertRowWithExistingColumns('selfserve_machine_types', [ + 'name' => 'Portal Machine ' . strtoupper($suffix), + 'description' => 'Fixture machine type with shared task configuration', + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + $this->cleanup->add(fn() => $this->deleteById('selfserve_machine_types', $machineTypeId)); + + $laneData = array_merge([ + 'department' => (int)$department['id'], + 'name' => 'Demo Lane ' . strtoupper($suffix), + 'relay_in_id' => $relayIds['entry'], + 'relay_out_id' => $relayIds['exit'], + 'relay_machine_id' => $relayIds['machine'], + 'relay_machine_program_picker_id' => $relayIds['program_picker'], + 'relay_machine_cleaner_id' => $relayIds['cleaner'], + 'dynamic_image_id' => 7000 + self::$sequence, + 'machine_type_id' => $machineTypeId, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ], is_array($overrides['lane'] ?? null) ? $overrides['lane'] : []); + $laneId = $this->insertRowWithExistingColumns('department_lanes', $laneData); + $this->cleanup->add(fn() => $this->deleteById('department_lanes', $laneId)); + + $customer = $this->createUser(array_merge([ + 'display_name' => 'API Self-Serve Customer ' . strtoupper($suffix), + 'economic_customer_name' => 'API Self-Serve Customer ' . strtoupper($suffix), + ], is_array($overrides['customer'] ?? null) ? $overrides['customer'] : [])); + $vehicle = $this->createVehicle(array_merge([ + 'customer_id' => (int)$customer['customer_number'], + 'type' => $productId, + 'reg' => 'TW' . strtoupper(substr($suffix, -4)) . '42', + 'wash_subscription' => 1, + 'reference' => 'fixture-vehicle-' . $suffix, + ], is_array($overrides['vehicle'] ?? null) ? $overrides['vehicle'] : [])); + + $conditionId = $this->insertRowWithExistingColumns('department_selfserve_conditions', [ + 'department' => (int)$department['id'], + 'lane' => $laneId, + 'product' => $productId, + 'machine_type_id' => $machineTypeId, + 'condition_id' => null, + 'name' => 'Vehicle preparation complete', + 'description' => 'The driver has completed the required pre-wash checks.', + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + $this->cleanup->add(fn() => $this->deleteById('department_selfserve_conditions', $conditionId)); + + $questionId = $this->insertRowWithExistingColumns('department_selfserve_questions', [ + 'department' => (int)$department['id'], + 'lane' => $laneId, + 'product' => $productId, + 'condition_id' => null, + 'question' => 'Is the tarp removed?', + 'description' => 'Required before the machine relay can be enabled.', + 'order_priority' => 1, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + $this->cleanup->add(fn() => $this->deleteById('department_selfserve_questions', $questionId)); + + $ruleId = $this->insertRowWithExistingColumns('department_selfserve_condition_rules', [ + 'condition_id' => $conditionId, + 'type' => 'IS_TRUE', + 'object_type' => 'question', + 'object_id' => $questionId, + 'name' => 'Tarp removed', + 'description' => 'Driver confirmed tarp removal.', + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + $this->cleanup->add(fn() => $this->deleteById('department_selfserve_condition_rules', $ruleId)); + + $prepareTaskId = $this->insertRowWithExistingColumns('department_selfserve_tasks', [ + 'department' => (int)$department['id'], + 'lane' => $laneId, + 'product' => $productId, + 'machine_type_id' => $machineTypeId, + 'condition_id' => $questionId, + 'gate_type' => 'QUESTION', + 'gate_ref_id' => $questionId, + 'task' => 'Prepare the vehicle', + 'description' => 'Remove loose equipment before starting the machine.', + 'order_priority' => 1, + 'services' => [], + 'buttons' => [1], + 'dynamic_images_vehicle_type' => $productId, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + $this->cleanup->add(fn() => $this->deleteById('department_selfserve_tasks', $prepareTaskId)); + + $machineTaskId = $this->insertRowWithExistingColumns('department_selfserve_tasks', [ + 'department' => (int)$department['id'], + 'lane' => $laneId, + 'product' => $productId, + 'machine_type_id' => $machineTypeId, + 'condition_id' => null, + 'gate_type' => 'CONDITION', + 'gate_ref_id' => $conditionId, + 'task' => 'Machine wash access', + 'description' => 'Enables the machine relay after the pre-wash checks pass.', + 'order_priority' => 2, + 'services' => ['MACHINE'], + 'buttons' => [2, 3], + 'dynamic_images_vehicle_type' => $productId, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + $this->cleanup->add(fn() => $this->deleteById('department_selfserve_tasks', $machineTaskId)); + + $vehicleConditionId = $this->insertRowWithExistingColumns('department_selfserve_vehicle_conditions', [ + 'department' => (int)$department['id'], + 'lane' => $laneId, + 'customer_id' => (int)$customer['customer_number'], + 'reg' => (string)$vehicle['reg'], + 'question' => $questionId, + 'value' => 1, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + $this->cleanup->add(fn() => $this->deleteById('department_selfserve_vehicle_conditions', $vehicleConditionId)); + + $sessionData = array_merge([ + 'lane_id' => $laneId, + 'department_id' => (int)$department['id'], + 'machine_type_id' => $machineTypeId, + 'customer_number' => (int)$customer['customer_number'], + 'vehicle_id' => (int)$vehicle['id'], + 'vehicle_type_id' => $productId, + 'reg' => (string)$vehicle['reg'], + 'status' => 'MACHINE_STARTED', + 'allowed' => 1, + 'machine_relay_enabled' => 1, + 'machine_relay_enabled_at' => $now, + 'machine_start_triggered' => 1, + 'machine_start_triggered_at' => $now, + 'wash_started_at' => $now, + 'order_id' => null, + 'completed_at' => null, + 'metadata_json' => [ + 'fixture' => 'selfserve', + 'relay_ids' => $relayIds, + 'evaluation_trace' => [ + ['task_id' => $machineTaskId, 'satisfied' => true], + ], + ], + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ], is_array($overrides['session'] ?? null) ? $overrides['session'] : []); + $sessionId = $this->insertRowWithExistingColumns('selfserve_wash_sessions', $sessionData); + $this->cleanup->add(fn() => $this->deleteById('selfserve_wash_sessions', $sessionId)); + + $answerId = $this->insertRowWithExistingColumns('selfserve_wash_session_answers', [ + 'session_id' => $sessionId, + 'question_id' => $questionId, + 'question_text' => 'Is the tarp removed?', + 'answer_value' => 1, + 'answered_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + $this->cleanup->add(fn() => $this->deleteById('selfserve_wash_session_answers', $answerId)); + + $sessionTaskIds = []; + foreach ([ + [ + 'task_id' => $prepareTaskId, + 'task_text' => 'Prepare the vehicle', + 'description' => 'Remove loose equipment before starting the machine.', + 'services' => [], + 'buttons' => [1], + ], + [ + 'task_id' => $machineTaskId, + 'task_text' => 'Machine wash access', + 'description' => 'Enables the machine relay after the pre-wash checks pass.', + 'services' => ['MACHINE'], + 'buttons' => [2, 3], + ], + ] as $taskSnapshot) { + $sessionTaskId = $this->insertRowWithExistingColumns('selfserve_wash_session_tasks', [ + 'session_id' => $sessionId, + 'task_id' => $taskSnapshot['task_id'], + 'task_text' => $taskSnapshot['task_text'], + 'description' => $taskSnapshot['description'], + 'services' => $taskSnapshot['services'], + 'buttons' => $taskSnapshot['buttons'], + 'dynamic_image_id' => null, + 'dynamic_images_vehicle_type' => $productId, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + $sessionTaskIds[] = $sessionTaskId; + $this->cleanup->add(fn() => $this->deleteById('selfserve_wash_session_tasks', $sessionTaskId)); + } + + $eventIds = []; + foreach ([ + ['SESSION_SYNCED', ['allowed' => true, 'source' => 'fixture']], + ['MACHINE_RELAY_ENABLED', ['relay_id' => $relayIds['machine']]], + ['MACHINE_START_TRIGGERED', ['lane_id' => $laneId]], + ] as [$eventType, $payload]) { + $eventId = $this->insertRowWithExistingColumns('selfserve_wash_session_events', [ + 'session_id' => $sessionId, + 'event_type' => $eventType, + 'payload_json' => $payload, + 'created_at' => $now, + ]); + $eventIds[] = $eventId; + $this->cleanup->add(fn() => $this->deleteById('selfserve_wash_session_events', $eventId)); + } + + $this->setModuleConfig('selfserve', 'enabled', 'true'); + + return [ + 'department' => $department, + 'category' => $category, + 'product' => ['id' => $productId] + $productData, + 'machine_type' => ['id' => $machineTypeId], + 'lane' => ['id' => $laneId] + $laneData, + 'customer' => $customer, + 'vehicle' => $vehicle, + 'condition' => ['id' => $conditionId], + 'question' => ['id' => $questionId], + 'rule' => ['id' => $ruleId], + 'tasks' => [ + ['id' => $prepareTaskId], + ['id' => $machineTaskId], + ], + 'vehicle_condition' => ['id' => $vehicleConditionId], + 'session' => ['id' => $sessionId] + $sessionData, + 'answer' => ['id' => $answerId], + 'session_tasks' => array_map(static fn(int $id): array => ['id' => $id], $sessionTaskIds), + 'events' => array_map(static fn(int $id): array => ['id' => $id], $eventIds), + 'relay_ids' => $relayIds, + ]; + } + /** * @param array $attributes * @return array @@ -190,6 +538,48 @@ final class ApiFixtures return ['id' => $categoryId]; } + /** + * @param array $attributes + * @return array + */ + public function createProduct(array $attributes = []): array + { + $categoryId = (int)($attributes['category'] ?? 0); + if ($categoryId <= 0) { + $category = $this->createCategory(); + $categoryId = (int)$category['id']; + } + + $productData = [ + 'name' => (string)($attributes['name'] ?? ('API Product ' . $this->uniqueSuffix())), + 'description' => (string)($attributes['description'] ?? 'API product'), + 'price' => (int)($attributes['price'] ?? 100), + 'subscription_allowed' => (int)($attributes['subscription_allowed'] ?? 1), + 'category' => $categoryId, + 'piktogram' => $attributes['piktogram'] ?? 'truck', + 'economic_product_id' => $attributes['economic_product_id'] ?? 0, + 'apply_category_discount' => (int)($attributes['apply_category_discount'] ?? 0), + 'requires_note' => (int)($attributes['requires_note'] ?? 0), + 'is_wash' => (int)($attributes['is_wash'] ?? 0), + 'display_in_booking_form' => (int)($attributes['display_in_booking_form'] ?? 1), + 'order_priority' => (int)($attributes['order_priority'] ?? 0), + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + 'deleted_at' => $attributes['deleted_at'] ?? null, + ]; + if (isset($attributes['id'])) { + $productData = ['id' => (int)$attributes['id']] + $productData; + } + + $productId = $this->insertRowWithExistingColumns('products', $productData); + + $this->deleteRedisPattern('obj_prop:products:' . $productId . ':*'); + $this->cleanup->add(fn() => $this->deleteById('products', $productId)); + $this->cleanup->add(fn() => $this->deleteRedisPattern('obj_prop:products:' . $productId . ':*')); + + return array_merge(['id' => $productId, 'category' => $categoryId], $this->fetchRowById('products', $productId) ?? []); + } + public function linkDepartmentCategory(int $departmentId, int $categoryId): int { $linkId = $this->insertRow('department_categories', [ @@ -294,6 +684,50 @@ final class ApiFixtures ]; } + /** + * @param array $attributes + * @return array + */ + public function createOrderBooking(array $attributes): array + { + $customerNumber = (int)($attributes['customer_number'] ?? 0); + $departmentId = (int)($attributes['department'] ?? $attributes['department_id'] ?? 0); + if ($customerNumber <= 0 || $departmentId <= 0) { + throw new RuntimeException('Order bookings require customer_number and department.'); + } + + $bookingId = $this->insertRow('order_bookings', [ + 'customer_number' => $customerNumber, + 'department' => $departmentId, + 'reg_1' => (string)($attributes['reg_1'] ?? 'BOOK123'), + 'reg_2' => (string)($attributes['reg_2'] ?? ''), + 'reg_3' => (string)($attributes['reg_3'] ?? ''), + 'datetime' => $attributes['datetime'] ?? $this->now(), + 'note' => (string)($attributes['note'] ?? ''), + 'reference' => (string)($attributes['reference'] ?? 'API-BOOKING'), + 'po' => (string)($attributes['po'] ?? ''), + 'pickup' => (int)($attributes['pickup'] ?? 0), + 'items' => $attributes['items'] ?? [], + 'order_id' => $attributes['order_id'] ?? null, + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + 'deleted_at' => $attributes['deleted_at'] ?? null, + ]); + + $this->cleanup->add(function () use ($bookingId): void { + $this->deleteById('order_bookings', $bookingId); + $this->deleteRedisKey('order_bookings_' . $bookingId . '_asArray'); + $this->deleteRedisPattern('order_bookings:*'); + }); + + return [ + 'id' => $bookingId, + 'customer_number' => $customerNumber, + 'department' => $departmentId, + 'order_id' => $attributes['order_id'] ?? null, + ]; + } + /** * @param array $attributes * @return array @@ -367,6 +801,34 @@ final class ApiFixtures ]; } + /** + * @param array $attributes + * @return array + */ + public function createOrderAttachment(array $attributes): array + { + $orderId = (int)($attributes['order_id'] ?? 0); + if ($orderId <= 0) { + throw new RuntimeException('Order attachments require order_id.'); + } + + $attachmentId = $this->insertRow('object_attachments', [ + 'object_type' => 'orders', + 'object_id' => $orderId, + 'content' => $attributes['content'] ?? '{"document":"api-test.pdf","other":"api-test.pdf"}', + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + 'deleted_at' => $attributes['deleted_at'] ?? null, + ]); + + $this->cleanup->add(fn() => $this->deleteById('object_attachments', $attachmentId)); + + return [ + 'id' => $attachmentId, + 'order_id' => $orderId, + ]; + } + /** * @param array $attributes * @return array @@ -518,13 +980,44 @@ final class ApiFixtures return $attributeId; } + public function preserveModuleConfig(string $module, string $variable): void + { + $existing = $this->fetchModuleConfig($module, $variable); + $conditions = [ + 'module' => $module, + 'variable' => $variable, + ]; + + $this->cleanup->add(function () use ($conditions, $existing): void { + if ($existing === null) { + $this->deleteWhereIfPossible('module_config', $conditions); + return; + } + + $current = $this->fetchModuleConfig((string)$conditions['module'], (string)$conditions['variable']); + $data = [ + 'value' => $existing['value'] ?? null, + 'type' => $existing['type'] ?? null, + 'created_at' => $existing['created_at'] ?? null, + 'updated_at' => $existing['updated_at'] ?? null, + ]; + + if ($current === null) { + $this->insertRow('module_config', [ + 'module' => $existing['module'] ?? $conditions['module'], + 'variable' => $existing['variable'] ?? $conditions['variable'], + ...$data, + ]); + return; + } + + $this->updateWhere('module_config', $conditions, $data); + }); + } + public function setModuleConfig(string $module, string $variable, string $value, string $type = 'bool'): void { - $moduleEscaped = $this->db->real_escape_string($module); - $variableEscaped = $this->db->real_escape_string($variable); - $existing = $this->queryOneBySql( - "SELECT * FROM module_config WHERE module = '{$moduleEscaped}' AND variable = '{$variableEscaped}' LIMIT 1" - ); + $existing = $this->fetchModuleConfig($module, $variable); if ($existing !== null) { $conditions = [ @@ -1113,6 +1606,21 @@ final class ApiFixtures $this->setRedisJson('`users`_' . $userId . '_economic_customer', $payload); } + /** + * @param array $data + */ + private function insertRowWithExistingColumns(string $table, array $data): int + { + $filtered = []; + foreach ($data as $column => $value) { + if ($this->tableHasColumn($table, (string)$column)) { + $filtered[(string)$column] = $value; + } + } + + return $this->insertRow($table, $filtered); + } + /** * @param array $data */ @@ -1444,6 +1952,16 @@ final class ApiFixtures return $row ?: null; } + private function fetchModuleConfig(string $module, string $variable): ?array + { + $moduleEscaped = $this->db->real_escape_string($module); + $variableEscaped = $this->db->real_escape_string($variable); + + return $this->queryOneBySql( + "SELECT * FROM module_config WHERE module = '{$moduleEscaped}' AND variable = '{$variableEscaped}' LIMIT 1" + ); + } + private function setRedisJson(string $key, array $payload): void { if ($this->redis === null) { @@ -1595,6 +2113,6 @@ final class ApiFixtures private function uniqueSuffix(): string { - return strtoupper(dechex(++self::$sequence)); + return strtoupper(dechex(time()) . dechex(getmypid()) . dechex(++self::$sequence)); } } diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index 92f9d758..d3df88ce 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -19,6 +19,9 @@ final class ApiSchemaBootstrap $this->execute($name, $sql); } + $this->ensureDepartmentArchiveSchema(); + $this->ensureOrderInvoiceCollectionSchema(); + foreach ($this->viewStatements() as $name => $sql) { $this->execute($name, $sql); } @@ -83,15 +86,17 @@ CREATE TABLE IF NOT EXISTS `departments` ( `economic_department_id` INT NOT NULL DEFAULT 0, `slack_webhook` TEXT NULL, `dimension` INT NOT NULL DEFAULT 0, - `branding` INT NOT NULL DEFAULT 0, + `branding` INT NULL DEFAULT NULL, `visible` TINYINT(1) NOT NULL DEFAULT 1, + `archived` TINYINT(1) NOT NULL DEFAULT 0, `latitude` DECIMAL(10,7) NOT NULL DEFAULT 0, `longitude` DECIMAL(10,7) NOT NULL DEFAULT 0, `order_priority` INT NOT NULL DEFAULT 0, `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), - KEY `idx_departments_visible` (`visible`) + KEY `idx_departments_visible` (`visible`), + KEY `idx_departments_archived` (`archived`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'department_variables' => <<<'SQL' @@ -122,6 +127,246 @@ CREATE TABLE IF NOT EXISTS `department_gates` ( KEY `idx_department_gates_department` (`department`), KEY `idx_department_gates_deleted_at` (`deleted_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'branding' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `branding` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NULL, + `description` TEXT NULL, + `cvr` INT NULL, + `address` VARCHAR(255) NULL, + `phone_country_code` INT NULL, + `phone` INT NULL, + `email` VARCHAR(255) NULL, + `website` VARCHAR(255) NULL, + `banner` VARCHAR(255) NULL, + `logo` VARCHAR(255) NULL, + `favicon` VARCHAR(255) NULL, + `signature` VARCHAR(255) NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_branding_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'department_lanes' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `department_lanes` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department` INT NOT NULL, + `name` VARCHAR(255) NOT NULL, + `relay_in_id` VARCHAR(255) NULL, + `relay_out_id` VARCHAR(255) NULL, + `relay_machine_id` VARCHAR(255) NULL, + `relay_machine_program_picker_id` VARCHAR(255) NULL, + `relay_machine_cleaner_id` VARCHAR(255) NULL, + `dynamic_image_id` INT NULL, + `machine_type_id` INT NULL, + `selfserve_enabled` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_department_lanes_department` (`department`), + KEY `idx_department_lanes_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'department_selfserve_conditions' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `department_selfserve_conditions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department` INT NOT NULL, + `lane` INT NULL, + `product` INT NULL, + `machine_type_id` INT NULL, + `condition_id` INT NULL, + `name` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_department_selfserve_conditions_department` (`department`), + KEY `idx_department_selfserve_conditions_lane` (`lane`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'department_selfserve_condition_rules' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `department_selfserve_condition_rules` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `condition_id` INT NOT NULL, + `type` VARCHAR(64) NOT NULL, + `object_type` VARCHAR(64) NOT NULL, + `object_id` INT NOT NULL, + `name` VARCHAR(255) NULL, + `description` TEXT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_department_selfserve_condition_rules_condition` (`condition_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'department_selfserve_questions' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `department_selfserve_questions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department` INT NOT NULL, + `lane` INT NULL, + `product` INT NULL, + `condition_id` INT NULL, + `question` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `order_priority` INT NOT NULL DEFAULT 0, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_department_selfserve_questions_department` (`department`), + KEY `idx_department_selfserve_questions_lane` (`lane`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'department_selfserve_tasks' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `department_selfserve_tasks` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department` INT NOT NULL, + `lane` INT NULL, + `product` INT NULL, + `machine_type_id` INT NULL, + `condition_id` INT NULL, + `gate_type` VARCHAR(16) NULL DEFAULT 'ALWAYS', + `gate_ref_id` INT NULL, + `task` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `order_priority` INT NOT NULL DEFAULT 0, + `services` JSON NULL, + `buttons` JSON NULL, + `dynamic_images_vehicle_type` INT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_department_selfserve_tasks_department` (`department`), + KEY `idx_department_selfserve_tasks_lane` (`lane`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'department_selfserve_vehicle_conditions' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `department_selfserve_vehicle_conditions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department` INT NOT NULL, + `lane` INT NULL, + `customer_id` INT NULL, + `reg` VARCHAR(64) NULL, + `question` INT NOT NULL, + `value` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_department_selfserve_vehicle_conditions_department` (`department`), + KEY `idx_department_selfserve_vehicle_conditions_reg` (`reg`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'selfserve_machine_types' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `selfserve_machine_types` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `description` VARCHAR(255) NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_selfserve_machine_types_name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'selfserve_wash_sessions' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `selfserve_wash_sessions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `lane_id` INT NOT NULL, + `department_id` INT NOT NULL, + `machine_type_id` INT NULL, + `customer_number` INT NULL, + `vehicle_id` INT NULL, + `vehicle_type_id` INT NULL, + `reg` VARCHAR(255) NOT NULL, + `status` VARCHAR(64) NOT NULL DEFAULT 'PENDING_QUESTIONS', + `allowed` TINYINT(1) NOT NULL DEFAULT 0, + `machine_relay_enabled` TINYINT(1) NOT NULL DEFAULT 0, + `machine_relay_enabled_at` DATETIME NULL, + `machine_start_triggered` TINYINT(1) NOT NULL DEFAULT 0, + `machine_start_triggered_at` DATETIME NULL, + `wash_started_at` DATETIME NULL, + `order_id` INT NULL, + `completed_at` DATETIME NULL, + `metadata_json` JSON NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_selfserve_wash_sessions_lane_reg` (`lane_id`, `reg`), + KEY `idx_selfserve_wash_sessions_status` (`status`), + KEY `idx_selfserve_wash_sessions_customer` (`customer_number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'selfserve_wash_session_answers' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `selfserve_wash_session_answers` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `session_id` INT NOT NULL, + `question_id` INT NOT NULL, + `question_text` VARCHAR(255) NOT NULL, + `answer_value` TINYINT(1) NOT NULL, + `answered_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_selfserve_wash_session_answer` (`session_id`, `question_id`), + KEY `idx_selfserve_wash_session_answers_session` (`session_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'selfserve_wash_session_tasks' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `selfserve_wash_session_tasks` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `session_id` INT NOT NULL, + `task_id` INT NULL, + `task_text` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `services` JSON NULL, + `buttons` JSON NULL, + `dynamic_image_id` INT NULL, + `dynamic_images_vehicle_type` INT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_selfserve_wash_session_tasks_session` (`session_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'selfserve_wash_session_events' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `selfserve_wash_session_events` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `session_id` INT NOT NULL, + `event_type` VARCHAR(64) NOT NULL, + `payload_json` JSON NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_selfserve_wash_session_events_session` (`session_id`), + KEY `idx_selfserve_wash_session_events_type` (`event_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'plate_scanners' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `plate_scanners` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department_id` INT NOT NULL, + `lane_id` INT NULL, + `name` VARCHAR(255) NOT NULL, + `notes` TEXT NULL, + `api_key` VARCHAR(191) NOT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_plate_scanners_department_id` (`department_id`), + KEY `idx_plate_scanners_lane_id` (`lane_id`), + KEY `idx_plate_scanners_api_key` (`api_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'categories' => <<<'SQL' CREATE TABLE IF NOT EXISTS `categories` ( @@ -148,6 +393,7 @@ CREATE TABLE IF NOT EXISTS `products` ( `is_wash` TINYINT(1) NOT NULL DEFAULT 0, `display_in_booking_form` TINYINT(1) NOT NULL DEFAULT 0, `order_priority` INT NOT NULL DEFAULT 0, + `max_quantity_per_order` INT NULL, `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted_at` DATETIME NULL, @@ -168,6 +414,18 @@ CREATE TABLE IF NOT EXISTS `department_categories` ( KEY `idx_department_categories_department_id` (`department_id`), KEY `idx_department_categories_category_id` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'product_department_prices' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `product_department_prices` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `department_id` INT NOT NULL, + `product_id` INT NOT NULL, + `price` INT NOT NULL DEFAULT 0, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_product_department_prices_lookup` (`department_id`, `product_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'collected_order_invoices' => <<<'SQL' CREATE TABLE IF NOT EXISTS `collected_order_invoices` ( @@ -215,7 +473,35 @@ CREATE TABLE IF NOT EXISTS `orders` ( KEY `idx_orders_customer_id` (`customer_id`), KEY `idx_orders_department_id` (`department_id`), KEY `idx_orders_invoice_collection_id` (`invoice_collection_id`), - KEY `idx_orders_reg_1` (`reg_1`) + KEY `idx_orders_reg_1` (`reg_1`), + KEY `idx_orders_period_customer_created_deleted` (`customer_id`, `created_at`, `deleted_at`), + KEY `idx_orders_period_created_deleted_customer` (`created_at`, `deleted_at`, `customer_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'order_bookings' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `order_bookings` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `customer_number` INT NOT NULL, + `department` INT NOT NULL, + `reg_1` VARCHAR(32) NULL, + `reg_2` VARCHAR(32) NULL, + `reg_3` VARCHAR(32) NULL, + `datetime` DATETIME NULL, + `note` TEXT NULL, + `reference` VARCHAR(255) NULL, + `po` VARCHAR(255) NULL, + `pickup` TINYINT(1) NOT NULL DEFAULT 0, + `items` LONGTEXT NULL, + `order_id` INT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_order_bookings_department` (`department`), + KEY `idx_order_bookings_customer_number` (`customer_number`), + KEY `idx_order_bookings_order_id` (`order_id`), + KEY `idx_order_bookings_reg_1` (`reg_1`), + KEY `idx_order_bookings_deleted_at` (`deleted_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'order_items' => <<<'SQL' @@ -235,7 +521,8 @@ CREATE TABLE IF NOT EXISTS `order_items` ( `deleted_at` DATETIME NULL, PRIMARY KEY (`id`), KEY `idx_order_items_order_id` (`order_id`), - KEY `idx_order_items_deleted_at` (`deleted_at`) + KEY `idx_order_items_deleted_at` (`deleted_at`), + KEY `idx_order_items_order_deleted` (`order_id`, `deleted_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'customer_vehicles' => <<<'SQL' @@ -266,6 +553,41 @@ CREATE TABLE IF NOT EXISTS `xlvask_vehicle_types` ( PRIMARY KEY (`id`), KEY `idx_xlvask_vehicle_types_product` (`product`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'xlvask_usage_logs' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `xlvask_usage_logs` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `WashId` VARCHAR(191) NOT NULL, + `CustomerId` VARCHAR(191) NULL, + `Customer` VARCHAR(255) NULL, + `VatNumber` VARCHAR(64) NULL, + `Location` VARCHAR(255) NULL, + `Hall` VARCHAR(255) NULL, + `HallId` VARCHAR(191) NULL, + `StartTime` VARCHAR(64) NULL, + `FinishTime` VARCHAR(64) NULL, + `RegistrationNumber` VARCHAR(64) NULL, + `VehicleType` VARCHAR(191) NULL, + `IdentificationType` VARCHAR(191) NULL, + `IdentificationId` VARCHAR(191) NULL, + `Info` TEXT NULL, + `Updated` VARCHAR(64) NULL, + `Prepaid` VARCHAR(64) NULL, + `FinishStatus` VARCHAR(64) NULL, + `CustomerGuid` VARCHAR(191) NULL, + `VehicleId` VARCHAR(191) NULL, + `WashItems` LONGTEXT NULL, + `ignored_at` DATETIME NULL, + `ignored_by` INT NULL, + `ignored_reason` TEXT NULL, + `cached_total_net_amount` DECIMAL(12,2) NULL, + `cached_primary_product_name` VARCHAR(255) NULL, + `cached_amount_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_xlvask_usage_logs_wash_id` (`WashId`), + KEY `idx_xlvask_usage_logs_customer` (`CustomerId`), + KEY `idx_xlvask_usage_logs_start` (`StartTime`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'customer_vehicles_addons' => <<<'SQL' CREATE TABLE IF NOT EXISTS `customer_vehicles_addons` ( @@ -336,7 +658,8 @@ CREATE TABLE IF NOT EXISTS `customer_attributes` ( `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_customer_attributes_user_id` (`user_id`), - KEY `idx_customer_attributes_attribute` (`attribute`) + KEY `idx_customer_attributes_attribute` (`attribute`), + KEY `idx_customer_attributes_attribute_user` (`attribute`, `user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'module_config' => <<<'SQL' @@ -455,6 +778,36 @@ CREATE TABLE IF NOT EXISTS `object_attachments` ( KEY `idx_object_attachments_lookup` (`object_type`, `object_id`), KEY `idx_object_attachments_deleted_at` (`deleted_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'invoice_period_flags' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `invoice_period_flags` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `source` VARCHAR(32) NOT NULL, + `severity` VARCHAR(32) NOT NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'active', + `target_type` VARCHAR(64) NOT NULL, + `target_id` BIGINT NOT NULL, + `field` VARCHAR(64) NULL, + `customer_number` INT NULL, + `order_id` BIGINT NULL, + `order_item_id` BIGINT NULL, + `invoice_collection_id` BIGINT NULL, + `xlvask_usage_log_id` BIGINT NULL, + `definition_key` VARCHAR(128) NULL, + `fingerprint` VARCHAR(191) NULL, + `reason` TEXT NULL, + `status_reason` TEXT NULL, + `context_json` JSON NULL, + `created_by` INT NULL, + `status_changed_by` INT NULL, + `status_changed_at` DATETIME NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_invoice_period_flags_auto_fingerprint` (`source`, `fingerprint`), + KEY `idx_invoice_period_flags_target` (`target_type`, `target_id`, `status`), + KEY `idx_invoice_period_flags_customer_status` (`customer_number`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, ]; } @@ -486,4 +839,77 @@ SQL, ); } } + + private function ensureDepartmentArchiveSchema(): void + { + if (!$this->columnExists('departments', 'archived')) { + $this->execute( + 'departments.archived', + 'ALTER TABLE `departments` ADD COLUMN `archived` TINYINT(1) NOT NULL DEFAULT 0 AFTER `visible`' + ); + } + + if (!$this->indexExists('departments', 'idx_departments_archived')) { + $this->execute( + 'departments.idx_departments_archived', + 'ALTER TABLE `departments` ADD INDEX `idx_departments_archived` (`archived`)' + ); + } + } + + private function ensureOrderInvoiceCollectionSchema(): void + { + if (!$this->columnExists('orders', 'invoice_collection_id')) { + $this->execute( + 'orders.invoice_collection_id', + 'ALTER TABLE `orders` ADD COLUMN `invoice_collection_id` INT NULL' + ); + } + + if (!$this->indexExists('orders', 'idx_orders_invoice_collection_id')) { + $this->execute( + 'orders.idx_orders_invoice_collection_id', + 'ALTER TABLE `orders` ADD INDEX `idx_orders_invoice_collection_id` (`invoice_collection_id`)' + ); + } + + if (!$this->columnExists('collected_order_invoices', 'processor')) { + $this->execute( + 'collected_order_invoices.processor', + 'ALTER TABLE `collected_order_invoices` ADD COLUMN `processor` INT NOT NULL DEFAULT 0' + ); + } + + if (!$this->columnExists('collected_order_invoices', 'booked_invoice_id')) { + $this->execute( + 'collected_order_invoices.booked_invoice_id', + 'ALTER TABLE `collected_order_invoices` ADD COLUMN `booked_invoice_id` INT NULL' + ); + } + + if (!$this->columnExists('collected_order_invoices', 'closed_at')) { + $this->execute( + 'collected_order_invoices.closed_at', + 'ALTER TABLE `collected_order_invoices` ADD COLUMN `closed_at` DATETIME NULL' + ); + } + } + + private function columnExists(string $table, string $column): bool + { + $table = $this->db->real_escape_string($table); + $column = $this->db->real_escape_string($column); + $result = $this->db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"); + + return $result !== false && $result->num_rows > 0; + } + + private function indexExists(string $table, string $index): bool + { + $table = $this->db->real_escape_string($table); + $index = $this->db->real_escape_string($index); + $result = $this->db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'"); + + return $result !== false && $result->num_rows > 0; + } } diff --git a/services/nginx/app/tests/Support/Api/ApiTestRuntime.php b/services/nginx/app/tests/Support/Api/ApiTestRuntime.php index 1cc04847..f5d7f940 100644 --- a/services/nginx/app/tests/Support/Api/ApiTestRuntime.php +++ b/services/nginx/app/tests/Support/Api/ApiTestRuntime.php @@ -38,6 +38,8 @@ final class ApiTestRuntime return 'API tests are disabled. Run with RUN_API_TESTS=1.'; } + $this->assertApiDatabaseTargetIsSafe(); + try { $this->bootstrapEnvironment(); $this->bootstrapSchemaIfRequested(); @@ -297,6 +299,8 @@ final class ApiTestRuntime $database = $this->readConfigValue('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target); $port = (int)($this->readConfigValue('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306'); + $this->assertApiDatabaseTargetIsSafe($target, $host, $user, $database, $port); + if ($host === '' || $user === '' || $database === '') { throw new RuntimeException('API tests require CONFIG_DB_HOST, CONFIG_DB_USER and CONFIG_DB_DATABASE to be set.'); } @@ -345,4 +349,47 @@ final class ApiTestRuntime return $liveValue; } + + private function assertApiDatabaseTargetIsSafe( + ?string $target = null, + ?string $host = null, + ?string $user = null, + ?string $database = null, + ?int $port = null + ): void { + if ((string)(getenv('API_TEST_ALLOW_LIVE_DB') ?: '') === '1') { + return; + } + + $target = strtolower(trim((string)($target ?? (getenv('CONFIG_DB_TARGET') ?: 'live')))); + if ($target !== 'debug') { + throw new RuntimeException( + 'Refusing to run API tests against CONFIG_DB_TARGET=live. Use CONFIG_DB_TARGET=debug, or set API_TEST_ALLOW_LIVE_DB=1 for an explicit override.' + ); + } + + $host ??= $this->readConfigValue('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target); + $user ??= $this->readConfigValue('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target); + $database ??= $this->readConfigValue('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target); + $port ??= (int)($this->readConfigValue('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306'); + + $liveHost = trim((string)(getenv('CONFIG_DB_HOST') ?: '')); + $liveUser = trim((string)(getenv('CONFIG_DB_USER') ?: '')); + $liveDatabase = trim((string)(getenv('CONFIG_DB_DATABASE') ?: '')); + $livePort = (int)(trim((string)(getenv('CONFIG_DB_PORT') ?: '3306')) ?: '3306'); + + if ( + $liveHost !== '' && + $liveUser !== '' && + $liveDatabase !== '' && + $host === $liveHost && + $user === $liveUser && + $database === $liveDatabase && + $port === $livePort + ) { + throw new RuntimeException( + 'Refusing to run API tests because CONFIG_DB_TARGET=debug resolves to the configured live database. Point CONFIG_DB_DEBUG_* at an isolated database, or set API_TEST_ALLOW_LIVE_DB=1 for an explicit override.' + ); + } + } } diff --git a/services/nginx/app/tests/Support/bootstrap.php b/services/nginx/app/tests/Support/bootstrap.php index 6226e575..a8691df3 100644 --- a/services/nginx/app/tests/Support/bootstrap.php +++ b/services/nginx/app/tests/Support/bootstrap.php @@ -103,6 +103,71 @@ if ($edgeBrokerSharedSecret === '') { $_SERVER['EDGE_BROKER_SHARED_SECRET'] = $edgeBrokerSharedSecret; } +$shellyGuardEnabled = trim((string)(getenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY') ?: '')); +if ($shellyGuardEnabled === '') { + putenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY=1'); + $_ENV['TRUCKWASH_TEST_BLOCK_REAL_SHELLY'] = '1'; + $_SERVER['TRUCKWASH_TEST_BLOCK_REAL_SHELLY'] = '1'; +} + +$shellyGuardLogPath = trim((string)(getenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG') ?: '')); +if ($shellyGuardLogPath === '') { + $shellyGuardLogPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'truckwash-shelly-guard-' . getmypid() . '.jsonl'; + @unlink($shellyGuardLogPath); + putenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG=' . $shellyGuardLogPath); + $_ENV['TRUCKWASH_TEST_SHELLY_GUARD_LOG'] = $shellyGuardLogPath; + $_SERVER['TRUCKWASH_TEST_SHELLY_GUARD_LOG'] = $shellyGuardLogPath; +} + +function shelly_test_guard_log_path(): ?string +{ + $path = trim((string)(getenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG') ?: '')); + return $path === '' ? null : $path; +} + +function shelly_test_guard_reset(): void +{ + if (class_exists(\classes\shelly::class, false)) { + \classes\shelly::resetBlockedRequestLog(); + } + + $path = shelly_test_guard_log_path(); + if ($path !== null && is_file($path)) { + @unlink($path); + } +} + +/** + * @return array> + */ +function shelly_test_guard_entries(): array +{ + $entries = []; + + if (class_exists(\classes\shelly::class, false)) { + $entries = array_merge($entries, \classes\shelly::blockedRequestLog()); + } + + $path = shelly_test_guard_log_path(); + if ($path === null || !is_file($path)) { + return $entries; + } + + $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines === false) { + return $entries; + } + + foreach ($lines as $line) { + $decoded = json_decode($line, true); + if (is_array($decoded)) { + $entries[] = $decoded; + } + } + + return $entries; +} + function run_legacy_script(string $relativeScriptPath): array { $script = app_path($relativeScriptPath); diff --git a/services/nginx/app/tests/Support/legacy_bootstrap.php b/services/nginx/app/tests/Support/legacy_bootstrap.php new file mode 100644 index 00000000..03775b98 --- /dev/null +++ b/services/nginx/app/tests/Support/legacy_bootstrap.php @@ -0,0 +1,128 @@ + '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_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_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_SHARED_SECRET' => 'truckwash-edge-ci', + 'EDGE_GATEWAY_VIEW_CACHE_TTL' => '0', + 'TRUCKWASH_TEST_BLOCK_REAL_SHELLY' => '1', +]; + +foreach ($legacyDefaults as $key => $value) { + if (getenv($key) !== false) { + continue; + } + + putenv($key . '=' . $value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; +} + +require_once WD . 'vendor/autoload.php'; +require_once WD . 'config.php'; + +if (getenv('API_TEST_BOOTSTRAP_SCHEMA') === '1') { + require_once WD . 'tests/Support/Api/ApiSchemaBootstrap.php'; + + $schemaDb = new mysqli( + getenv('CONFIG_DB_HOST') ?: 'mysql-debug', + getenv('CONFIG_DB_USER') ?: 'root', + getenv('CONFIG_DB_PASSWORD') ?: 'debug_root_password', + getenv('CONFIG_DB_DATABASE') ?: 'nnks_db_debug', + (int)(getenv('CONFIG_DB_PORT') ?: 3306) + ); + + if ($schemaDb->connect_errno) { + fwrite(STDERR, 'Unable to bootstrap legacy schema: ' . $schemaDb->connect_error . PHP_EOL); + exit(1); + } + + (new Tests\Support\Api\ApiSchemaBootstrap($schemaDb))->ensureSchema(); + $schemaDb->close(); +} + +if (!isset($GLOBALS['db']) || !$GLOBALS['db'] instanceof classes\db) { + $GLOBALS['db'] = new classes\db($GLOBALS['CONFIG_DB']); + $GLOBALS['db']->connect(); +} + +$GLOBALS['db']->query(" + INSERT INTO departments (id, name, visible, archived) + VALUES (1, 'CI Self-Serve Department', 1, 0) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + visible = VALUES(visible), + archived = VALUES(archived) +"); +$GLOBALS['db']->query(" + INSERT INTO department_lanes (id, department, name, relay_machine_id, selfserve_enabled) + VALUES (1, 1, 'CI Lane', 'demo-machine-relay', 1) + ON DUPLICATE KEY UPDATE + department = VALUES(department), + name = VALUES(name), + relay_machine_id = VALUES(relay_machine_id), + selfserve_enabled = VALUES(selfserve_enabled), + deleted_at = NULL +"); +$GLOBALS['db']->query(" + DELETE FROM department_variables + WHERE department_id = 1 + AND variable = 'selfserve_enabled' +"); +$GLOBALS['db']->query(" + INSERT INTO department_variables (department_id, variable, value) + VALUES (1, 'selfserve_enabled', 'true') +"); + +if (!defined('redis')) { + try { + define('redis', (new classes\redis())->connect()); + } catch (Throwable $throwable) { + fwrite(STDERR, 'Unable to connect Redis for legacy test bootstrap: ' . $throwable->getMessage() . PHP_EOL); + exit(1); + } +} diff --git a/services/nginx/app/tests/Support/legacy_test_manifest.php b/services/nginx/app/tests/Support/legacy_test_manifest.php new file mode 100644 index 00000000..abc43d5a --- /dev/null +++ b/services/nginx/app/tests/Support/legacy_test_manifest.php @@ -0,0 +1,33 @@ + 'tests/auth/CreateTokenUserNotFoundTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/auth/PasskeyChallengeTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/auth/PemToCoseConversionTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/auth/RegisterCvrTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/auth/TwoFactorAuthTest.php', 'classification' => 'integration', 'type' => 'script'], + ['path' => 'tests/auth/WebAuthnInstallTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/bookingModule/BookingModuleTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/bookingModule/bookingSyncTest.php', 'classification' => 'manual-external', 'type' => 'script', 'reason' => 'Requires the live WordPress bookings API and wash certificate object storage.'], + ['path' => 'tests/dynamicimages/DepartmentLanesImageTest.php', 'classification' => 'unit', 'type' => 'script', 'bootstrap' => 'lite'], + ['path' => 'tests/economicOrderParser/economicOrderParserTest.php', 'classification' => 'manual-external', 'type' => 'script', 'reason' => 'Calls the live e-conomic API and is not deterministic in CI.'], + ['path' => 'tests/goals/DepartmentDailyTargetsRendererTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/goals/MonthlyTargetRendererTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/goalsModule/goalsTest.php', 'classification' => 'unit', 'type' => 'phpunit'], + ['path' => 'tests/lanes/DepartmentLaneDynamicImageIdTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/minio/minioTest.php', 'classification' => 'integration', 'type' => 'script'], + ['path' => 'tests/permissions/PermissionNodeTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/permissions/PermissionRedisCacheTest.php', 'classification' => 'integration', 'type' => 'script'], + ['path' => 'tests/redis/redisLogSyncTest.php', 'classification' => 'integration', 'type' => 'script'], + ['path' => 'tests/redis/redisTest.php', 'classification' => 'integration', 'type' => 'script'], + ['path' => 'tests/selfserve/ButtonsNormalizationTest.php', 'classification' => 'unit', 'type' => 'script', 'bootstrap' => 'lite'], + ['path' => 'tests/selfserve/DynamicImagesVehicleTypeNormalizationTest.php', 'classification' => 'unit', 'type' => 'script', 'bootstrap' => 'lite'], + ['path' => 'tests/selfserve/ForceMachineRelayBypassTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/selfserve/SelfserveLaneServicesEnumTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/selfserve/SelfServeRelayGatingTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/selfserve/StopTurnsOffRelayTest.php', 'classification' => 'unit', 'type' => 'script', 'bootstrap' => 'lite'], + ['path' => 'tests/slackModule/SlackModuleTest.php', 'classification' => 'manual-external', 'type' => 'script', 'reason' => 'Requires a configured Slack webhook and department webhook cache state.'], + ['path' => 'tests/subusers/SelfservePermissionInitTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/subusers/SubusersRoutePermissionLinkTest.php', 'classification' => 'unit', 'type' => 'script'], + ['path' => 'tests/subusers/SubuserUserGrantInitTest.php', 'classification' => 'unit', 'type' => 'script'], +]; diff --git a/services/nginx/app/tests/Support/run_ci_suite.php b/services/nginx/app/tests/Support/run_ci_suite.php new file mode 100644 index 00000000..586e04a6 --- /dev/null +++ b/services/nginx/app/tests/Support/run_ci_suite.php @@ -0,0 +1,110 @@ + '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', + 'EDGE_BROKER_SHARED_SECRET' => 'truckwash-edge-ci', + 'EDGE_GATEWAY_VIEW_CACHE_TTL' => '0', + 'TRUCKWASH_TEST_BLOCK_REAL_SHELLY' => '1', +]; + +foreach ($commonEnv as $key => $value) { + putenv($key . '=' . $value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; +} + +$commands = [ + 'unit' => [ + 'vendor/bin/pest --testsuite=Unit --colors=always', + ], + 'integration' => [ + 'RUN_INTEGRATION_TESTS=1 vendor/bin/pest --testsuite=Integration --colors=always', + ], + 'api' => [ + 'RUN_API_TESTS=1 API_TEST_BOOTSTRAP_SCHEMA=1 API_TEST_ALLOW_LIVE_DB=1 API_TEST_REQUEST_TIMEOUT=180 vendor/bin/pest --testsuite=Api --colors=always', + ], + 'legacy' => [ + 'RUN_LEGACY_TESTS=1 RUN_INTEGRATION_TESTS=1 RUN_API_TESTS=1 API_TEST_BOOTSTRAP_SCHEMA=1 API_TEST_ALLOW_LIVE_DB=1 API_TEST_REQUEST_TIMEOUT=180 vendor/bin/pest --testsuite=Legacy --colors=always', + ], +]; + +function reset_ci_state(): void +{ + $database = getenv('CONFIG_DB_DATABASE') ?: 'nnks_db_debug'; + if (!preg_match('/^[A-Za-z0-9_]+$/', $database)) { + fwrite(STDERR, 'Refusing to reset unsafe database name: ' . $database . PHP_EOL); + exit(2); + } + + $db = new mysqli( + getenv('CONFIG_DB_HOST') ?: 'mysql-debug', + getenv('CONFIG_DB_USER') ?: 'root', + getenv('CONFIG_DB_PASSWORD') ?: 'debug_root_password', + '', + (int)(getenv('CONFIG_DB_PORT') ?: 3306) + ); + + if ($db->connect_errno) { + fwrite(STDERR, 'Unable to reset CI database: ' . $db->connect_error . PHP_EOL); + exit(1); + } + + $db->query("DROP DATABASE IF EXISTS `{$database}`"); + $db->query("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + $db->close(); + + $redisHost = escapeshellarg(getenv('REDIS_CONFIG_HOST') ?: 'redis'); + $redisPort = (int)(getenv('REDIS_CONFIG_PORT') ?: 6379); + $redisDb = (int)(getenv('REDIS_CONFIG_DATABASE') ?: 0); + passthru("redis-cli -h {$redisHost} -p {$redisPort} -n {$redisDb} FLUSHDB >/dev/null", $redisExitCode); + if ($redisExitCode !== 0) { + fwrite(STDERR, 'Unable to reset CI Redis database.' . PHP_EOL); + exit($redisExitCode); + } +} + +if ($suite === 'all') { + foreach (['unit', 'integration', 'api', 'legacy'] as $selectedSuite) { + reset_ci_state(); + foreach ($commands[$selectedSuite] as $command) { + passthru($command, $exitCode); + if ($exitCode !== 0) { + exit($exitCode); + } + } + } + exit(0); +} elseif (isset($commands[$suite])) { + $selectedCommands = $commands[$suite]; +} else { + fwrite(STDERR, "Usage: php tests/Support/run_ci_suite.php " . PHP_EOL); + exit(2); +} + +foreach ($selectedCommands as $command) { + passthru($command, $exitCode); + if ($exitCode !== 0) { + exit($exitCode); + } +} diff --git a/services/nginx/app/tests/Support/run_legacy_script.php b/services/nginx/app/tests/Support/run_legacy_script.php new file mode 100644 index 00000000..305185ae --- /dev/null +++ b/services/nginx/app/tests/Support/run_legacy_script.php @@ -0,0 +1,34 @@ +getMessage() . PHP_EOL); + fwrite(STDERR, $throwable->getFile() . ':' . $throwable->getLine() . PHP_EOL); + exit(1); +} diff --git a/services/nginx/app/tests/Tooling/composer-entrypoint-autoload-recovery.sh b/services/nginx/app/tests/Tooling/composer-entrypoint-autoload-recovery.sh index a43bcb6f..c5860829 100644 --- a/services/nginx/app/tests/Tooling/composer-entrypoint-autoload-recovery.sh +++ b/services/nginx/app/tests/Tooling/composer-entrypoint-autoload-recovery.sh @@ -2,8 +2,12 @@ set -eu tmp_dir="$(mktemp -d)" +psr_tmp_dir="" cleanup() { rm -rf "$tmp_dir" + if [ -n "$psr_tmp_dir" ]; then + rm -rf "$psr_tmp_dir" + fi } trap cleanup EXIT HUP INT TERM @@ -40,10 +44,75 @@ AUTO_COMPOSER_INSTALL=true \ APP_DIR="$tmp_dir" \ MODULE_DIR="$tmp_dir/no-module" \ LOG_FILE="$tmp_dir/composer-install.log" \ +REDIS_CONFIG_HOST= \ /usr/local/bin/docker-entrypoint.sh \ php -r "require \$argv[1]; echo class_exists('FixtureClass') ? 'autoload-ok' . PHP_EOL : 'autoload-missing' . PHP_EOL;" \ "$tmp_dir/vendor/autoload.php" >/dev/null php -d display_errors=1 -r "require \$argv[1]; exit(class_exists('FixtureClass') ? 0 : 1);" "$tmp_dir/vendor/autoload.php" +psr_tmp_dir="$(mktemp -d)" +cat > "$psr_tmp_dir/composer.json" <<'JSON' +{ + "name": "truckwash/composer-entrypoint-psr-fixture", + "require": { + "psr/http-message": "^2.0" + } +} +JSON + +COMPOSER_ALLOW_SUPERUSER=1 composer install \ + --no-dev \ + --prefer-dist \ + --optimize-autoloader \ + --no-interaction \ + -d "$psr_tmp_dir" >/dev/null 2>&1 + +cat > "$psr_tmp_dir/corrupt-autoload.php" <<'PHP' + "__DIR__ . '/../..' . '/modules/washcertificates/vendor/psr/http-message/src/StreamInterface.php'", + "__DIR__ . '/..' . '/psr/http-message/src/UriInterface.php'" => "__DIR__ . '/../..' . '/modules/washcertificates/vendor/psr/http-message/src/UriInterface.php'", + "\$vendorDir . '/psr/http-message/src/StreamInterface.php'" => "\$vendorDir . '/../modules/washcertificates/vendor/psr/http-message/src/StreamInterface.php'", + "\$vendorDir . '/psr/http-message/src/UriInterface.php'" => "\$vendorDir . '/../modules/washcertificates/vendor/psr/http-message/src/UriInterface.php'", +]; + +foreach (['autoload_static.php', 'autoload_classmap.php'] as $file) { + $path = $dir . '/vendor/composer/' . $file; + $contents = file_get_contents($path); + if ($contents === false) { + fwrite(STDERR, "Unable to read $path\n"); + exit(1); + } + + $updated = str_replace(array_keys($replacements), array_values($replacements), $contents); + if ($updated === $contents) { + fwrite(STDERR, "Fixture did not corrupt $path\n"); + exit(1); + } + + file_put_contents($path, $updated); +} +PHP + +php "$psr_tmp_dir/corrupt-autoload.php" "$psr_tmp_dir" + +if php -d display_errors=1 -r "require \$argv[1]; exit(interface_exists('Psr\\\\Http\\\\Message\\\\UriInterface') ? 0 : 1);" "$psr_tmp_dir/vendor/autoload.php" >/dev/null 2>&1; then + echo "fixture failed to corrupt psr/http-message autoload map" >&2 + exit 1 +fi + +AUTO_COMPOSER_INSTALL=true \ +APP_DIR="$psr_tmp_dir" \ +MODULE_DIR="$psr_tmp_dir/no-module" \ +LOG_FILE="$psr_tmp_dir/composer-install.log" \ +REDIS_CONFIG_HOST= \ +/usr/local/bin/docker-entrypoint.sh \ +php -d display_errors=1 -r "require \$argv[1]; exit(interface_exists('Psr\\\\Http\\\\Message\\\\UriInterface') && interface_exists('Psr\\\\Http\\\\Message\\\\StreamInterface') ? 0 : 1);" \ +"$psr_tmp_dir/vendor/autoload.php" + +php -d display_errors=1 -r "require \$argv[1]; exit(interface_exists('Psr\\\\Http\\\\Message\\\\UriInterface') && interface_exists('Psr\\\\Http\\\\Message\\\\StreamInterface') ? 0 : 1);" "$psr_tmp_dir/vendor/autoload.php" + echo "composer-entrypoint-autoload-recovery-ok" diff --git a/services/nginx/app/tests/Unit/Auth/CreateTokenTest.php b/services/nginx/app/tests/Unit/Auth/CreateTokenTest.php index cffac520..10d7378b 100644 --- a/services/nginx/app/tests/Unit/Auth/CreateTokenTest.php +++ b/services/nginx/app/tests/Unit/Auth/CreateTokenTest.php @@ -1,5 +1,15 @@ null, + 'created' => [], + 'missing_error' => null, + ]; - $token = (new \classes\authentication())->create_token(111111); - - expect($token)->toBeString()->toMatch('/^[a-f0-9]{64}$/'); - expect(\objects\tokens_o::$created)->toHaveCount(1); - expect(\objects\tokens_o::$created[0]['user_id'])->toBe(123); - expect(\objects\tokens_o::$created[0]['type'])->toBe('AUTH_TOKEN'); - }); - - it('throws a clear exception when customer user is missing', function (): void { - \objects\users_o::$existing = []; + \objects\users_o::$existing = [111111]; + \objects\tokens_o::$created = []; + $result['success_token'] = (new \classes\authentication())->create_token(111111); + $result['created'] = \objects\tokens_o::$created; + \objects\users_o::$existing = []; + try { (new \classes\authentication())->create_token(222222); - })->throws(\Exception::class, 'User not found for customer number: 222222'); -} + } catch (\Throwable $exception) { + $result['missing_error'] = $exception->getMessage(); + } + echo json_encode($result, JSON_THROW_ON_ERROR); +} +PHP)); + + try { + $output = []; + $exitCode = 0; + exec(PHP_BINARY . ' ' . escapeshellarg($script), $output, $exitCode); + + expect($exitCode)->toBe(0); + $result = json_decode(implode("\n", $output), true, 512, JSON_THROW_ON_ERROR); + + expect($result['success_token'])->toBeString()->toMatch('/^[a-f0-9]{64}$/') + ->and($result['created'])->toHaveCount(1) + ->and($result['created'][0]['user_id'])->toBe(123) + ->and($result['created'][0]['type'])->toBe('AUTH_TOKEN') + ->and($result['missing_error'])->toBe('User not found for customer number: 222222'); + } finally { + if (is_file($script)) { + unlink($script); + } + } +}); diff --git a/services/nginx/app/tests/Unit/Auth/EconomicCreateCustomerResponseTest.php b/services/nginx/app/tests/Unit/Auth/EconomicCreateCustomerResponseTest.php index d0f3ed2f..08974950 100644 --- a/services/nginx/app/tests/Unit/Auth/EconomicCreateCustomerResponseTest.php +++ b/services/nginx/app/tests/Unit/Auth/EconomicCreateCustomerResponseTest.php @@ -60,4 +60,40 @@ it('returns the raw upstream create response and preserves the requested payload expect($probe->inner->lastPayload['customerNumber'])->toBe(42331123); expect($probe->inner->lastPayload['corporateIdentificationNumber'])->toBe('37781258'); expect($probe->inner->lastPayload['phone'])->toBe(42331123); + expect($probe->inner->lastPayload['telephoneAndFaxNumber'])->toBe('42331123'); + expect($probe->inner->lastPayload['mobilePhone'])->toBe('42331123'); +}); + +it('adds supported CVR company fields to the e-conomic customer payload', function (): void { + $stubResponse = (object)[ + 'customerNumber' => 42331123, + 'name' => 'Truckwash ApS', + ]; + $companyInformation = (object)[ + 'address' => 'Testvej 12', + 'zipcode' => 2630, + 'city' => 'Taastrup', + 'website' => 'https://truckwash.test', + 'industrycode' => 953190, + ]; + + $probe = new EconomicCreateCustomerProbe($stubResponse); + $result = $probe->createCustomer( + 42331123, + 'Truckwash ApS', + 37781258, + 'invoice@truckwash.test', + 42331123, + 55667788, + $companyInformation, + ); + + expect($result)->toBe($stubResponse); + expect($probe->inner->lastPayload['address'])->toBe('Testvej 12'); + expect($probe->inner->lastPayload['zip'])->toBe('2630'); + expect($probe->inner->lastPayload['city'])->toBe('Taastrup'); + expect($probe->inner->lastPayload['website'])->toBe('https://truckwash.test'); + expect($probe->inner->lastPayload['telephoneAndFaxNumber'])->toBe('42331123'); + expect($probe->inner->lastPayload['mobilePhone'])->toBe('55667788'); + expect(array_key_exists('industrycode', $probe->inner->lastPayload))->toBeFalse(); }); diff --git a/services/nginx/app/tests/Unit/Bird/DepartmentGatesRelayOpenTest.php b/services/nginx/app/tests/Unit/Bird/DepartmentGatesRelayOpenTest.php index 086b9df2..74271a1c 100644 --- a/services/nginx/app/tests/Unit/Bird/DepartmentGatesRelayOpenTest.php +++ b/services/nginx/app/tests/Unit/Bird/DepartmentGatesRelayOpenTest.php @@ -16,7 +16,7 @@ final class DepartmentGatesRelayManagerFake extends edge_gateway_manager { } - public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on): array + public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on, array $actionContext = []): array { $this->switchCalls[] = [ 'department_id' => $departmentId, @@ -44,6 +44,10 @@ final class DepartmentGatesRelayOpenHarness extends department_gates_o $departmentProperty->set($departmentId); $this->department = $departmentProperty; + $nameProperty = new object_property('department_gates', -1, 'name', 'string'); + $nameProperty->set('Entry gate'); + $this->name = $nameProperty; + $configProperty = new object_property('department_gates', -1, 'config', 'json'); $configProperty->set($config); $this->config = $configProperty; diff --git a/services/nginx/app/tests/Unit/Bookings/NonPosBookingCompletionRemovalTest.php b/services/nginx/app/tests/Unit/Bookings/NonPosBookingCompletionRemovalTest.php new file mode 100644 index 00000000..8f4b9a64 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bookings/NonPosBookingCompletionRemovalTest.php @@ -0,0 +1,29 @@ +toBeFalse(); + expect(is_file(app_path('modules/forms/objects/generate_booking_wash_certificate_f.php')))->toBeFalse(); + expect($code)->not->toContain('complete_booking_f'); + expect($code)->not->toContain('generate_booking_wash_certificate_f'); + expect($code)->not->toContain('COMPLETE_BOOKING_WITHOUT_WASH_CERTIFICATE'); + expect($code)->not->toContain('GENERATE_BOOKING_WASH_CERTIFICATE'); +}); + +it('keeps legacy wash certificate downloads but disables generation and completion', function (): void { + $code = (string)file_get_contents(app_path('modules/washcertificates/index.php')); + + $downloadPosition = strpos($code, "isset(\$_GET['justDownload'])"); + $disabledPosition = strpos($code, 'http_response_code(410)'); + $completionPosition = strpos($code, "\$booking->status->set('completed')"); + + expect($downloadPosition)->not->toBeFalse(); + expect($disabledPosition)->not->toBeFalse(); + expect($completionPosition)->not->toBeFalse(); + expect($downloadPosition)->toBeLessThan($disabledPosition); + expect($disabledPosition)->toBeLessThan($completionPosition); + expect($code)->toContain('Booking completion must be completed through POS desktop or mobile steps.'); +}); + diff --git a/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php b/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php new file mode 100644 index 00000000..6dbeb714 --- /dev/null +++ b/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php @@ -0,0 +1,739 @@ +targets = array_values($targets); + } + + public function getLoadBalancer(int|string $id): array + { + return [ + 'id' => $id, + 'targets' => array_map( + static fn(string $ip): array => ['type' => 'ip', 'ip' => ['ip' => $ip]], + $this->targets + ), + 'services' => [], + ]; + } + + public function addIpTarget(int|string $loadBalancerId, string $ip): array + { + if (!in_array($ip, $this->targets, true)) { + $this->targets[] = $ip; + } + return []; + } + + public function removeIpTarget(int|string $loadBalancerId, string $ip): array + { + $this->targets = array_values(array_filter($this->targets, static fn(string $target): bool => $target !== $ip)); + return []; + } + + public function addService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array + { + return []; + } + + public function updateService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array + { + return []; + } +} + +function coolifyManagerTestLoadBalancerService(string $protocol, int $listenPort, int $destinationPort): array +{ + return [ + 'protocol' => $protocol, + 'listen_port' => $listenPort, + 'destination_port' => $destinationPort, + 'proxyprotocol' => false, + 'health_check' => [ + 'protocol' => 'tcp', + 'port' => $listenPort, + 'interval' => 15, + 'timeout' => 10, + 'retries' => 3, + ], + ]; +} + +it('normalizes Coolify API base URLs to the v1 API root', function (): void { + expect(coolify_api_client::normalizeBaseUrl('https://coolify.example.com'))->toBe('https://coolify.example.com/api/v1'); + expect(coolify_api_client::normalizeBaseUrl('https://coolify.example.com/api/v1'))->toBe('https://coolify.example.com/api/v1'); + expect(coolify_api_client::normalizeBaseUrl(' https://coolify.example.com/ '))->toBe('https://coolify.example.com/api/v1'); +}); + +it('parses generated env files for Coolify service env bulk updates', function (): void { + $env = implode("\n", [ + '# generated', + 'MARIADB_ROOT_PASSWORD=root-secret', + 'MARIADB_PASSWORD=app-secret', + '', + 'REDIS_PRIMARY_USERNAME=', + ]); + + expect(coolify_manager::parseEnvFile($env))->toBe([ + 'MARIADB_ROOT_PASSWORD' => 'root-secret', + 'MARIADB_PASSWORD' => 'app-secret', + 'REDIS_PRIMARY_USERNAME' => '', + ]); +}); + +it('prefers public Coolify server hosts over Docker-local addresses', function (): void { + expect(coolify_manager::publicServerHostFromCoolifyServer([ + 'ip' => 'host.docker.internal', + 'public_ip' => '94.130.142.41', + 'name' => 'node3.truckwash.io', + ]))->toBe('94.130.142.41'); + + expect(coolify_manager::publicServerHostFromCoolifyServer([ + 'ip' => 'host.docker.internal', + 'name' => 'node3.truckwash.io', + ]))->toBe('node3.truckwash.io'); + + expect(coolify_manager::publicServerHostFromCoolifyServer([ + 'ip' => 'host.docker.internal', + 'name' => 'node3.truckwash.io', + ], null, false))->toBeNull(); + + expect(coolify_manager::publicServerHostFromCoolifyServer([ + 'ip' => '10.0.0.10', + 'name' => 'Production Server', + ]))->toBe('10.0.0.10'); + + expect(coolify_manager::publicServerHostFromCoolifyServer([ + 'ip' => 'host.docker.internal', + 'name' => 'Production Server', + ]))->toBeNull(); + + expect(coolify_manager::publicDnsServerNameFromCoolifyServer([ + 'ip' => 'host.docker.internal', + 'name' => 'node3.truckwash.io', + ]))->toBe('node3.truckwash.io'); + + expect(coolify_manager::publicDnsServerNameFromCoolifyServer([ + 'ip' => 'host.docker.internal', + 'name' => 'Production Server', + ]))->toBeNull(); +}); + +it('blocks planned downtime operations against active replication primaries', function (): void { + expect(coolify_manager::blocksPrimaryMutation(['role' => 'primary'], 'deploy'))->toBeTrue(); + expect(coolify_manager::blocksPrimaryMutation(['role' => 'primary'], 'restart'))->toBeTrue(); + expect(coolify_manager::blocksPrimaryMutation(['role' => 'replica'], 'restart'))->toBeFalse(); + expect(coolify_manager::blocksPrimaryMutation(['role' => 'primary'], 'failover'))->toBeFalse(); +}); + +it('allows failed Coolify replica targets to be removed after the service disappears', function (): void { + expect(coolify_manager::targetAllowsReplicaRemoval([ + 'role' => 'replica', + 'deployment_status' => 'reconcile_failed', + 'last_reconcile_status' => 'reconcile_failed', + ]))->toBeTrue(); + + expect(coolify_manager::targetAllowsReplicaRemoval([ + 'role' => 'replica', + 'deployment_status' => 'deploying', + 'last_reconcile_json' => json_encode(['message' => 'Coolify API request failed: HTTP 404']), + ]))->toBeTrue(); + + expect(coolify_manager::targetAllowsReplicaRemoval([ + 'role' => 'replica', + 'deployment_status' => 'provisioned', + 'last_reconcile_status' => 'ok', + ]))->toBeFalse(); + + expect(coolify_manager::targetAllowsReplicaRemoval([ + 'role' => 'primary', + 'deployment_status' => 'reconcile_failed', + ]))->toBeFalse(); +}); + +it('retries Coolify maintenance while linked replication provisioning is still incomplete', function (): void { + $method = new ReflectionMethod(coolify_manager::class, 'replicationHostStillNeedsProvisioning'); + $method->setAccessible(true); + + expect($method->invoke(null, [ + 'role' => 'replica', + 'status' => 'provisioning', + 'last_status_json' => json_encode([ + 'status' => 'provisioning', + 'replication_percent' => 99.9, + 'blockers' => ['MinIO replica has not caught up.'], + ]), + ]))->toBeTrue(); + + expect($method->invoke(null, [ + 'role' => 'replica', + 'status' => 'ok', + 'last_status_json' => json_encode([ + 'status' => 'ok', + 'replication_percent' => 100, + 'blockers' => [], + ]), + ]))->toBeFalse(); +}); + +it('plans Hetzner load balancer target and service drift without mutating state', function (): void { + $manager = new coolify_manager(); + $method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile'); + $method->setAccessible(true); + + $plan = $method->invoke($manager, [ + 'targets' => [ + ['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']], + ], + 'services' => [ + coolifyManagerTestLoadBalancerService('http', 80, 80), + ], + ], [ + ['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => true], + ['hostname' => 'node2.truckwash.io', 'target_ip' => '65.21.214.30', 'enabled' => true], + ]); + + $actionTypes = array_map(static fn(array $action): string => (string)$action['type'], $plan['actions']); + + expect($plan['has_drift'])->toBeTrue() + ->and($plan['missing_targets'])->toContain('65.21.214.30') + ->and($actionTypes)->toContain('add_target') + ->and($actionTypes)->toContain('add_service') + ->and($plan['missing_services'][0])->toMatchArray([ + 'protocol' => 'tcp', + 'listen_port' => 443, + 'destination_port' => 443, + ]); +}); + +it('plans Hetzner load balancer service health check drift updates', function (): void { + $manager = new coolify_manager(); + $method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile'); + $method->setAccessible(true); + + $plan = $method->invoke($manager, [ + 'targets' => [ + ['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']], + ], + 'services' => [ + [ + 'protocol' => 'http', + 'listen_port' => 80, + 'destination_port' => 80, + 'proxyprotocol' => false, + 'health_check' => [ + 'protocol' => 'http', + 'port' => 80, + 'interval' => 15, + 'timeout' => 10, + 'retries' => 3, + 'http' => [ + 'domain' => '', + 'path' => '/', + 'response' => '', + 'status_codes' => ['2??', '3??'], + 'tls' => false, + ], + ], + ], + coolifyManagerTestLoadBalancerService('tcp', 443, 443), + ], + ], [ + ['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => true], + ]); + + expect($plan['actions'])->toHaveCount(1) + ->and($plan['actions'][0])->toMatchArray([ + 'type' => 'update_service', + 'reason' => 'health_check_drift', + 'protocol' => 'http', + 'listen_port' => 80, + 'destination_port' => 80, + 'health_check' => [ + 'protocol' => 'tcp', + 'port' => 80, + 'interval' => 15, + 'timeout' => 10, + 'retries' => 3, + ], + ]); +}); + +it('does not plan removal of the last Hetzner load balancer target', function (): void { + $manager = new coolify_manager(); + $method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile'); + $method->setAccessible(true); + + $plan = $method->invoke($manager, [ + 'targets' => [ + ['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']], + ], + 'services' => [ + coolifyManagerTestLoadBalancerService('http', 80, 80), + coolifyManagerTestLoadBalancerService('tcp', 443, 443), + ], + ], [ + ['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => false], + ]); + + expect($plan['actions'][0]) + ->toHaveKey('type', 'skip_remove_target') + ->toHaveKey('reason', 'last_reachable_target_guard'); +}); + +it('plans removal only for disabled or deleted Hetzner load balancer targets', function (): void { + $manager = new coolify_manager(); + $method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile'); + $method->setAccessible(true); + + $plan = $method->invoke($manager, [ + 'targets' => [ + ['type' => 'ip', 'ip' => ['ip' => '94.130.142.41']], + ['type' => 'ip', 'ip' => ['ip' => '65.21.214.30']], + ], + 'services' => [ + coolifyManagerTestLoadBalancerService('http', 80, 80), + coolifyManagerTestLoadBalancerService('tcp', 443, 443), + ], + ], [ + ['hostname' => 'node1.truckwash.io', 'target_ip' => '94.130.142.41', 'enabled' => true], + ['hostname' => 'node2.truckwash.io', 'target_ip' => '65.21.214.30', 'enabled' => true, 'deleted_at' => '2026-05-19 10:00:00'], + ]); + + expect($plan['actions']) + ->toHaveCount(1) + ->and($plan['actions'][0]) + ->toHaveKey('type', 'remove_target') + ->toHaveKey('target_ip', '65.21.214.30'); +}); + +it('builds gateway API auto-provision context for connected Coolify servers', function (): void { + $manager = new coolify_manager(); + $contextMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteProvisionDeployContext'); + $contextMethod->setAccessible(true); + $ipMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteServerPublicIp'); + $ipMethod->setAccessible(true); + + $server = [ + 'uuid' => 'server-node1', + 'name' => 'node1.truckwash.io', + 'public_ip' => '94.130.142.41', + 'settings' => ['is_reachable' => true, 'is_usable' => true], + ]; + + expect($ipMethod->invoke(null, $server))->toBe('94.130.142.41'); + + $context = $contextMethod->invoke($manager, [ + 'id' => 42, + 'channel_slug' => 'internal', + 'deploy_context_json' => json_encode([ + 'coolify_project_uuid' => 'project-internal', + 'coolify_base_directory' => 'services/nginx/app', + 'coolify_dockerfile_location' => 'services/php/Dockerfile', + 'coolify_ports_exposes' => '9000', + 'coolify_start_command' => 'php-fpm', + 'coolify_destination_uuid' => 'source-destination', + 'coolify_git_commit_sha' => 'source-commit', + 'coolify_enable_ssl' => false, + ]), + ], $server, '94.130.142.41', 'api-v2.truckwash.io', 'https://api-v2.truckwash.io'); + + expect($context)->toMatchArray([ + 'coolify_project_uuid' => 'project-internal', + 'coolify_auto_create' => true, + 'coolify_enable_ssl' => true, + 'coolify_deploy_now' => true, + 'coolify_build_pack' => 'dockerfile', + 'coolify_dockerfile_location' => '/Dockerfile.coolify-api', + 'coolify_ports_exposes' => '80', + 'coolify_port' => '80', + 'coolify_domain' => 'api-v2.truckwash.io', + 'coolify_public_url' => 'https://api-v2.truckwash.io', + 'coolify_server_uuid' => 'server-node1', + 'server_uuid' => 'server-node1', + 'coolify_destination_uuid' => '', + 'destination_uuid' => '', + 'coolify_service_name' => 'release-internal-api-node1-truckwash-io', + 'gateway_route_autoprovision' => true, + 'gateway_route_source_target_id' => 42, + 'gateway_route_target_ip' => '94.130.142.41', + ]); + expect($context)->not->toHaveKey('coolify_git_commit_sha'); + expect($context)->not->toHaveKey('coolify_base_directory'); + expect($context)->not->toHaveKey('coolify_start_command'); +}); + +it('builds gateway frontend auto-provision context with the release Dockerfile', function (): void { + $manager = new coolify_manager(); + $contextMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteProvisionDeployContext'); + $contextMethod->setAccessible(true); + + $server = [ + 'uuid' => 'server-node3', + 'name' => 'node3.truckwash.io', + 'public_ip' => '23.88.23.183', + 'settings' => ['is_reachable' => true, 'is_usable' => true], + ]; + + $context = $contextMethod->invoke($manager, [ + 'id' => 43, + 'channel_slug' => 'internal', + 'app' => 'frontend', + 'deploy_context_json' => json_encode([ + 'coolify_project_uuid' => 'project-internal', + 'coolify_build_pack' => 'static', + 'coolify_install_command' => 'npm ci', + 'coolify_build_command' => 'npm run build', + 'coolify_publish_directory' => 'dist', + 'coolify_is_static' => true, + 'coolify_is_spa' => true, + ]), + ], $server, '23.88.23.183', 'api-v2.truckwash.io', 'https://api-v2.truckwash.io/internal/frontend'); + + expect($context)->toMatchArray([ + 'coolify_project_uuid' => 'project-internal', + 'coolify_build_pack' => 'dockerfile', + 'coolify_dockerfile_location' => '/Dockerfile.coolify-frontend', + 'coolify_ports_exposes' => '80', + 'coolify_port' => '80', + 'coolify_public_url' => 'https://api-v2.truckwash.io/internal/frontend', + 'coolify_server_uuid' => 'server-node3', + 'coolify_service_name' => 'release-internal-frontend-node3-truckwash-io', + 'gateway_route_autoprovision' => true, + 'gateway_route_source_target_id' => 43, + 'gateway_route_target_ip' => '23.88.23.183', + ]); + expect($context)->not->toHaveKey('coolify_install_command'); + expect($context)->not->toHaveKey('coolify_publish_directory'); + expect($context)->not->toHaveKey('coolify_is_static'); + expect($context)->not->toHaveKey('coolify_is_spa'); +}); + +it('adds explicit Coolify application route labels for gateway API domains', function (): void { + $payloadMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteApplicationPayload'); + $payloadMethod->setAccessible(true); + $publicUrlMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteTargetPublicUrl'); + $publicUrlMethod->setAccessible(true); + + $payload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io', 'api-app-uuid', 8080, base64_encode(implode("\n", [ + 'custom.keep=true', + 'traefik.http.routers.https-0-api-app-uuid.entryPoints=old', + 'traefik.http.routers.https-0-api-app-uuid.tls.certresolver=dns-cloudflare', + 'traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=9090', + ]))); + $labels = explode("\n", base64_decode($payload['custom_labels'], true)); + + expect($payload['domains'])->toBe('https://api-v2.truckwash.io:8080') + ->and($payload['is_force_https_enabled'])->toBeTrue() + ->and($payload['force_domain_override'])->toBeTrue() + ->and($labels)->toContain('custom.keep=true') + ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)') + ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.entryPoints=https') + ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt') + ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io') + ->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080'); + + expect($publicUrlMethod->invoke(null, 'api-v2.truckwash.io', [ + 'channel_slug' => 'stable', + 'channel_default_channel' => 1, + 'app' => 'api', + ]))->toBe('https://api-v2.truckwash.io'); + expect($publicUrlMethod->invoke(null, 'api-v2.truckwash.io', [ + 'channel_slug' => 'internal', + 'channel_default_channel' => 0, + 'app' => 'api', + ]))->toBe('https://api-v2.truckwash.io/internal/api'); + expect($publicUrlMethod->invoke(null, 'api-v2.truckwash.io', [ + 'channel_slug' => 'internal', + 'channel_default_channel' => 0, + 'app' => 'frontend', + ]))->toBe('https://api-v2.truckwash.io/internal/frontend'); + + $pathPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/api', 'api-app-uuid', 8080, ''); + $pathLabels = explode("\n", base64_decode($pathPayload['custom_labels'], true)); + + expect($pathPayload['domains'])->toBe('https://api-v2.truckwash.io:8080/internal/api') + ->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)') + ->and($pathLabels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-stripprefix.stripprefix.prefixes=/internal/api') + ->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.middlewares=https-0-api-app-uuid-stripprefix,gzip'); + + $frontendPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/frontend', 'frontend-app-uuid', 80, ''); + $frontendLabels = explode("\n", base64_decode($frontendPayload['custom_labels'], true)); + + expect($frontendPayload['domains'])->toBe('https://api-v2.truckwash.io:80/internal/frontend') + ->and($frontendLabels)->toContain('traefik.http.routers.https-0-frontend-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/frontend`)') + ->and($frontendLabels)->toContain('traefik.http.middlewares.https-0-frontend-app-uuid-stripprefix.stripprefix.prefixes=/internal/frontend') + ->and($frontendLabels)->toContain('traefik.http.routers.https-0-frontend-app-uuid.middlewares=https-0-frontend-app-uuid-stripprefix,gzip'); +}); + +it('isolates and restores Hetzner load balancer IP targets for gateway certificate bootstrap', function (): void { + $manager = new coolify_manager(); + $method = new ReflectionMethod(coolify_manager::class, 'setLoadBalancerIpTargets'); + $method->setAccessible(true); + $client = new CoolifyManagerHetznerTargetSetFake([ + '94.130.142.41', + '65.21.214.30', + '23.88.23.183', + ]); + + $method->invoke($manager, $client, '6366569', ['65.21.214.30']); + $isolated = $client->targets; + sort($isolated); + + $method->invoke($manager, $client, '6366569', [ + '94.130.142.41', + '65.21.214.30', + '23.88.23.183', + ]); + $restored = $client->targets; + sort($restored); + + expect($isolated)->toBe(['65.21.214.30']) + ->and($restored)->toBe([ + '23.88.23.183', + '65.21.214.30', + '94.130.142.41', + ]); +}); + +it('requires gateway ping probes to return the API ping contract', function (): void { + $method = new ReflectionMethod(coolify_manager::class, 'gatewayProbePingContract'); + $method->setAccessible(true); + + expect($method->invoke(null, json_encode([ + 'success' => true, + 'data' => ['message' => 'pong'], + ])))->toMatchArray(['ok' => true, 'message' => 'pong']); + + expect($method->invoke(null, 'Fatal error'))->toMatchArray([ + 'ok' => false, + 'reason' => 'invalid_json', + ]); +}); + +it('normalizes gateway probe paths for release gateway health checks', function (): void { + $method = new ReflectionMethod(coolify_manager::class, 'normalizeGatewayProbePath'); + $method->setAccessible(true); + + expect($method->invoke(null, 'internal/api/ping'))->toBe('/internal/api/ping') + ->and($method->invoke(null, '//internal//api//ping//'))->toBe('/internal/api/ping') + ->and($method->invoke(null, 'https://api-v2.truckwash.io/internal/api/ping'))->toBe('/internal/api/ping') + ->and($method->invoke(null, ''))->toBe(''); +}); + +it('returns structured errors for failed gateway certificate bootstrap and verification', function (): void { + $method = new ReflectionMethod(coolify_manager::class, 'gatewayRouteHealthErrors'); + $method->setAccessible(true); + + $errors = $method->invoke(null, [ + 'ok' => false, + 'reason' => 'load_balancer_enforce_required', + 'results' => [ + ['target_ip' => '94.130.142.41', 'ok' => false], + ['target_ip' => '65.21.214.30', 'ok' => true], + ['target_ip' => '23.88.23.183', 'ok' => false], + ], + ], [ + 'ok' => false, + 'failed_target_ips' => ['94.130.142.41', '23.88.23.183'], + 'results' => [], + ]); + + expect($errors)->toHaveCount(2) + ->and($errors[0])->toMatchArray([ + 'type' => 'certificate_bootstrap_failed', + 'reason' => 'load_balancer_enforce_required', + 'failed_target_ips' => ['94.130.142.41', '23.88.23.183'], + ]) + ->and($errors[1])->toMatchArray([ + 'type' => 'gateway_route_verification_failed', + 'failed_target_ips' => ['94.130.142.41', '23.88.23.183'], + ]); +}); + +it('defines Coolify schema, route permissions, and replication integration hooks', function (): void { + $schema = file_get_contents(app_path('classes/coolify_schema_bootstrap.php')); + $manager = file_get_contents(app_path('classes/coolify_manager.php')); + $route = file_get_contents(app_path('routes/superuserCoolifyRoute.php')); + $replication = file_get_contents(app_path('classes/replication_manager.php')); + $status = file_get_contents(app_path('classes/superuser_system_status_service.php')); + $cron = file_get_contents(app_path('cron/Cron.php')); + $openapi = file_get_contents(app_path('openapi.yaml')); + $coolifyConfig = file_get_contents(app_path('modules/coolify/coolify_c.php')); + $tokenConfig = file_get_contents(app_path('modules/coolify/config/coolify_hetzner_cloud_api_token_c.php')); + + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_instances'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_targets'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_operations'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_audit_logs'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS coolify_instance_gateways'); + expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'lb_automation_enabled'"); + expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'lb_automation_mode'"); + expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'hetzner_load_balancer_id'"); + expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'hetzner_cloud_api_token'"); + expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'public_gateway_host', 'api-v2.truckwash.io'"); + expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'public_gateway_probe_path'"); + expect($schema)->toContain('94.130.142.41'); + expect($schema)->toContain('65.21.214.30'); + expect($schema)->toContain('23.88.23.183'); + expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'enabled'"); + + expect($route)->toContain('/superuser/coolify'); + expect($route)->toContain('/superuser/coolify/load-balancer'); + expect($route)->toContain('/superuser/coolify/load-balancer/reconcile'); + expect($route)->toContain('/superuser/coolify/load-balancer/routes/deploy'); + expect($route)->toContain('/superuser/coolify/load-balancer/api/deploy'); + expect($route)->toContain('/superuser/coolify/gateways'); + expect($route)->toContain('/superuser/coolify/gateways/{id}/test'); + expect($route)->toContain('/superuser/coolify/instances/{id}/test'); + expect($route)->toContain('/superuser/coolify/instances/{id}/placement'); + expect($route)->toContain('/superuser/coolify/targets/{id}/reconcile'); + expect($route)->toContain('/superuser/coolify/targets/{id}/deploy'); + expect($route)->toContain('/superuser/coolify/targets/{id}/restart'); + expect($route)->toContain('/superuser/coolify/targets/{id}/failover'); + expect($route)->toContain("requirePermission('superuser_coolify_view')"); + expect($route)->toContain("requirePermission('superuser_coolify_manage')"); + expect($route)->toContain("requirePermission('superuser_coolify_reconcile')"); + expect($route)->toContain("requirePermission('superuser_coolify_failover')"); + + expect($manager)->toContain("Coolify-managed targets must be deployed as replicas first"); + expect($manager)->toContain('ensureFailoverEnabled($kind)'); + expect($manager)->toContain("deployment_provider' => 'coolify'"); + expect($manager)->toContain('discoverInstancePlacement'); + expect($manager)->toContain('applyCoolifyDeploymentDefaults($input, $instance)'); + expect($manager)->toContain('resolveCoolifyServerHost'); + expect($manager)->toContain('publicServerHostFromCoolifyServer'); + expect($manager)->toContain('applyCoolifyPortDefaults'); + expect($manager)->toContain('syncReplicationHostPortsForTarget'); + expect($manager)->toContain('usedPublicPortsForCoolifyServer'); + expect($manager)->toContain('nextAvailablePublicPorts'); + expect($manager)->toContain('syncReplicationHostEndpointForTarget'); + expect($manager)->toContain('knownPublicHostForCoolifyServer'); + expect($manager)->toContain('publicDnsServerNameFromCoolifyServer'); + expect($manager)->toContain('resolvedPublicDnsServerHostFromCoolifyServer'); + expect($manager)->toContain('recordCreatedResource'); + expect($manager)->toContain("'start_requested'"); + expect($manager)->toContain('primaryCredentials'); + expect($manager)->toContain('primary_admin_password'); + expect($manager)->toContain('replication_transfer_limit'); + expect($manager)->toContain('startOrRestartService'); + expect($manager)->toContain('already running'); + expect($manager)->toContain('restart_requested'); + expect($manager)->toContain('isolated_stack'); + expect($manager)->toContain('skip_replication_provisioning'); + expect($manager)->toContain('targetSkipsReplicationProvisioning'); + expect($manager)->toContain('targetComposeRole'); + expect($manager)->toContain('production_data_attached'); + expect($manager)->toContain('deferredProvisionResult'); + expect($manager)->toContain('isTransientProvisionBlock'); + expect($manager)->toContain('provision_deferred'); + expect($manager)->toContain('shouldRetryProvisioning'); + expect($manager)->toContain('shouldRetryProvisioning($target, $host)'); + expect($manager)->toContain('hasRunningReplicationProvisionOperation'); + expect($manager)->toContain('replicationHostStillNeedsProvisioning'); + expect($manager)->toContain('syncDeploymentStateForReplicationHost'); + expect($manager)->toContain('syncLabelForReplicationHost'); + expect($manager)->toContain('syncTargetsForReplicationHost'); + expect($manager)->toContain('targetAllowsReplicaRemoval'); + expect($manager)->toContain('markTargetsRemovedForReplicationHost'); + expect($manager)->toContain('replicationHostIsReady'); + expect($manager)->toContain("'provisioned'"); + expect($manager)->not->toContain('is_container_label_escape_enabled'); + expect($manager)->not->toContain("\$payload['type'] = 'docker-compose';"); + expect($manager)->toContain('encodedDockerCompose'); + expect($manager)->toContain('base64_encode'); + expect($manager)->toContain('if (!$update)'); + expect($manager)->toContain("'project_uuid' => \$target['project_uuid']"); + expect($manager)->toContain('/api/v1/services'); + expect($manager)->toContain('/envs/bulk'); + expect($manager)->toContain('loadBalancerSummary'); + expect($manager)->toContain('reconcileLoadBalancer'); + expect($manager)->toContain('deployGatewayApplicationRoutes'); + expect($manager)->toContain('deployGatewayApiCode'); + expect($manager)->toContain('gateway_api_code_deploy'); + expect($manager)->toContain('deploy_gateway_route_after_code'); + expect($manager)->toContain('loadBalancerReleaseGatewayTargets'); + expect($manager)->toContain('provisionMissingGatewayRouteTargets'); + expect($manager)->toContain('provision_gateway_'); + expect($manager)->toContain('gateway_route_autoprovision'); + expect($manager)->toContain('upsertDeploymentTarget'); + expect($manager)->toContain('startDeployment'); + expect($manager)->toContain("'commit_mode' => \$sourceCommitSha === '' ? 'latest' : 'specific'"); + expect($manager)->toContain("\$deploymentInput['commit_sha'] = \$sourceCommitSha;"); + expect($manager)->toContain('verifyGatewayRoutes'); + expect($manager)->toContain('bootstrapGatewayCertificates'); + expect($manager)->toContain('setLoadBalancerIpTargets'); + expect($manager)->toContain('probeGatewayPublicHost'); + expect($manager)->toContain('GATEWAY_CERT_BOOTSTRAP_ATTEMPTS'); + expect($manager)->toContain('certificate_bootstrap'); + expect($manager)->toContain('certificate_bootstrap_failed'); + expect($manager)->toContain('gateway_route_verification_failed'); + expect($manager)->toContain('recordGatewayProbe'); + expect($manager)->toContain("Gateway route and Let's Encrypt certificate verification is still failing"); + expect($manager)->toContain('CURLOPT_SSL_VERIFYHOST, 2'); + expect($manager)->toContain('CURLOPT_SSL_VERIFYPEER, true'); + expect($manager)->toContain('CURLOPT_CERTINFO'); + expect($manager)->toContain('CURLINFO_SSL_VERIFYRESULT'); + expect($manager)->toContain("Gateway TLS certificate was not issued by Let's Encrypt."); + expect($manager)->toContain('gatewayProbePath'); + expect($manager)->toContain('public_gateway_probe_path'); + expect($manager)->toContain('loadBalancerReleaseApiTargets'); + expect($manager)->toContain('gatewayProbePingContract'); + expect($manager)->toContain('Gateway ping response did not match the expected API contract.'); + expect($manager)->not->toContain('CURLOPT_SSL_VERIFYHOST, 0'); + expect($manager)->not->toContain('CURLOPT_SSL_VERIFYPEER, false'); + expect($manager)->toContain('information_schema.tables'); + expect($manager)->not->toContain('SHOW TABLES LIKE ?'); + expect($manager)->toContain('gatewayRouteApplicationPayload'); + expect($manager)->toContain('gateway_application_routes_deployed'); + expect($manager)->toContain('target_already_defined'); + expect($manager)->toContain('REQUIRED_LOAD_BALANCER_SERVICES'); + expect($manager)->toContain('skip_remove_target'); + + $composer = json_decode((string)file_get_contents(app_path('composer.json')), true); + expect($composer['autoload']['exclude-from-classmap'] ?? [])->toContain('modules/*/vendor/'); + + $client = file_get_contents(app_path('classes/coolify_api_client.php')); + expect($client)->toContain("request('GET', '/health', null, false)"); + expect($client)->toContain('/github-apps'); + expect($client)->toContain('/applications/private-github-app'); + expect($client)->toContain("request('GET', '/applications/' . rawurlencode(\$uuid))"); + expect($client)->toContain("'/deploy?uuid=' . rawurlencode(\$uuid)"); + expect($client)->toContain('/applications/\' . rawurlencode($uuid) . \'/restart'); + expect($client)->toContain('CURL_HTTP_VERSION_1_1'); + expect($client)->toContain('validationErrorSummary'); + + expect($replication)->toContain('deployment_provider'); + expect($replication)->toContain('coolify_manager::deploymentMetadataForReplicationHost'); + expect($replication)->toContain('coolify_manager::syncDeploymentStateForReplicationHost'); + expect($replication)->toContain('databaseEngineKnown'); + expect($status)->toContain("'key' => 'coolify'"); + expect($status)->toContain('probeCoolifyModule'); + expect($cron)->toContain('CoolifyAvailabilityMonitorCron'); + expect($cron)->toContain('CoolifyLoadBalancerReconcileCron'); + expect($coolifyConfig)->toContain('[redacted]'); + expect($coolifyConfig)->toContain('secret_set'); + expect($tokenConfig)->toContain('replication_secret_box::encrypt'); + expect($openapi)->toContain('/superuser/coolify:'); + expect($openapi)->toContain('operationId: getSuperuserCoolifyLoadBalancer'); + expect($openapi)->toContain('operationId: reconcileSuperuserCoolifyLoadBalancer'); + expect($openapi)->toContain('operationId: deploySuperuserCoolifyGatewayRoutes'); + expect($openapi)->toContain('operationId: deploySuperuserCoolifyGatewayApiCode'); + expect($openapi)->toContain('operationId: listSuperuserCoolifyGateways'); + expect($openapi)->toContain('operationId: testSuperuserCoolifyGateway'); + expect($openapi)->toContain('operationId: discoverSuperuserCoolifyInstancePlacement'); + expect($openapi)->toContain('operationId: createSuperuserCoolifyTarget'); + expect($openapi)->toContain('SuperuserCoolifyTarget'); + expect($openapi)->toContain('SuperuserCoolifyLoadBalancer'); + expect($openapi)->toContain('SuperuserCoolifyGateway'); +}); diff --git a/services/nginx/app/tests/Unit/Database/DbObjectRedisNamespaceSafetyTest.php b/services/nginx/app/tests/Unit/Database/DbObjectRedisNamespaceSafetyTest.php new file mode 100644 index 00000000..7e6e0fe5 --- /dev/null +++ b/services/nginx/app/tests/Unit/Database/DbObjectRedisNamespaceSafetyTest.php @@ -0,0 +1,9 @@ +not->toContain('redis->') + ->and($content)->toContain("constant('redis')"); +}); diff --git a/services/nginx/app/tests/Unit/DynamicImages/DepartmentLaneDynamicImageRouteTest.php b/services/nginx/app/tests/Unit/DynamicImages/DepartmentLaneDynamicImageRouteTest.php new file mode 100644 index 00000000..d6a7bb48 --- /dev/null +++ b/services/nginx/app/tests/Unit/DynamicImages/DepartmentLaneDynamicImageRouteTest.php @@ -0,0 +1,74 @@ +not->toBeFalse(); + expect($content)->toContain("isRequestParameterSet('dynamic_image_id')"); + expect($content)->toContain("\$dynamic_image_override = \$response->getRequestParameter('dynamic_image_id')"); + expect($content)->toContain("self::requireMinValue(\$dynamic_image_id, 1)"); + expect($content)->toContain("switch (\$dynamic_image_id)"); + }); + + it('accepts ordered dynamic image button tokens including program picker reset start and zero', function (): void { + expect(department_selfserve_tasks_o::normalizeButtonsInput('["program_picker","reset",0,2,"start",5]'))->toBe([ + 'program_picker', + 'reset', + 0, + 2, + 'start', + 5, + ]); + + $route = file_get_contents(app_path('routes/departmentLanesRoute.php')); + expect($route)->not->toBeFalse(); + expect($route)->toContain('ordered highlighted button IDs'); + expect($route)->toContain('"reset", "start", or "program_picker"'); + }); + + it('renders machine one dynamic image steps from the ordered button payload', function (): void { + $content = file_get_contents(app_path('modules/dynamicimages/images/machine_1.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('$deferredStartButtons = $this->drawHighlightedButtonSequence();'); + expect($content)->toContain('getOrderedHighlightedButtonTokens'); + expect($content)->toContain('normalizeHighlightedButtonToken'); + expect($content)->toContain("const BUTTON_PROGRAM_PICKER = 'program_picker'"); + expect($content)->toContain('getProgramPickerStepCoordinates'); + expect($content)->toContain('drawDeferredHighlightedButtons($deferredStartButtons)'); + expect($content)->toContain('self::BUTTON_PROGRAM_PICKER'); + + $setupOffset = strpos($content, 'public function setup'); + expect($setupOffset)->not->toBeFalse(); + $setupBody = substr($content, (int)$setupOffset, 1000); + expect($setupBody)->not->toContain('drawStepThumb();'); + }); +} diff --git a/services/nginx/app/tests/Unit/DynamicImages/DynamicImagePreRenderCronWiringTest.php b/services/nginx/app/tests/Unit/DynamicImages/DynamicImagePreRenderCronWiringTest.php index a701bd57..d206591b 100644 --- a/services/nginx/app/tests/Unit/DynamicImages/DynamicImagePreRenderCronWiringTest.php +++ b/services/nginx/app/tests/Unit/DynamicImages/DynamicImagePreRenderCronWiringTest.php @@ -9,6 +9,8 @@ it('registers dynamic image pre-render cron task and related helpers', function expect($content)->toContain('collectDynamicImageTaskGroupsForLane'); expect($content)->toContain('buildDynamicImageVariantsForTaskGroup'); expect($content)->toContain('renderDynamicImageVariant'); + expect($content)->toContain('mergeUniqueButtonValues'); + expect($content)->toContain('normalizeButtonsInput'); expect($content)->toContain('dynamic_image:'); expect($content)->toContain('machine_1'); }); diff --git a/services/nginx/app/tests/Unit/ErrorReports/ErrorReportTest.php b/services/nginx/app/tests/Unit/ErrorReports/ErrorReportTest.php new file mode 100644 index 00000000..b4fa2684 --- /dev/null +++ b/services/nginx/app/tests/Unit/ErrorReports/ErrorReportTest.php @@ -0,0 +1,79 @@ + 'Bearer secret-token', + 'request' => [ + 'headers' => [ + 'X-Api-Key' => 'hidden', + 'Accept' => 'application/json', + ], + 'body' => [ + 'password' => 'not stored', + 'safe' => 'visible', + ], + ], + ]; + + expect(error_report_service::redactPayload($payload))->toBe([ + 'Authorization' => '[redacted]', + 'request' => [ + 'headers' => [ + 'X-Api-Key' => '[redacted]', + 'Accept' => 'application/json', + ], + 'body' => [ + 'password' => '[redacted]', + 'safe' => 'visible', + ], + ], + ]); +}); + +it('validates supported screenshot data uris', function (): void { + $decoded = error_report_service::decodeScreenshotDataUri('data:image/png;base64,' . base64_encode('png-bytes')); + + expect($decoded['mime_type'])->toBe('image/png'); + expect($decoded['contents'])->toBe('png-bytes'); + expect($decoded['size_bytes'])->toBe(strlen('png-bytes')); + + expect(fn() => error_report_service::decodeScreenshotDataUri('data:text/plain;base64,' . base64_encode('nope'))) + ->toThrow(RuntimeException::class, 'Screenshot must be a PNG, JPEG, or WebP data URI.'); +}); + +it('defines error report schema, routes, permissions, storage, and OpenAPI docs', function (): void { + $schema = file_get_contents(app_path('classes/error_report_schema_bootstrap.php')); + $service = file_get_contents(app_path('classes/error_report_service.php')); + $store = file_get_contents(app_path('classes/error_report_store.php')); + $route = file_get_contents(app_path('routes/errorReportRoute.php')); + $openapi = file_get_contents(app_path('openapi.yaml')); + + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS error_reports'); + expect($schema)->toContain('request_errors_json'); + expect($schema)->toContain('vue_errors_json'); + expect($schema)->toContain('data_collection_accepted_at'); + expect($schema)->toContain('resolved_by_user_id'); + + expect($service)->toContain('createFromCurrentPrincipal'); + expect($service)->toContain('decodeScreenshotDataUri'); + expect($service)->toContain('data_collection_accepted'); + expect($service)->toContain('request_error_count'); + expect($service)->toContain('vue_error_count'); + expect($store)->toContain("error-reports/%s/%s.%s"); + + expect($route)->toContain('/error-reports'); + expect($route)->toContain('/superuser/error-reports'); + expect($route)->toContain('/superuser/error-reports/{id}/status'); + expect($route)->toContain("requirePermission('superuser_error_reports_view')"); + expect($route)->toContain("requirePermission('superuser_error_reports_resolve')"); + + expect($openapi)->toContain('/error-reports:'); + expect($openapi)->toContain('ErrorReportSubmissionRequest'); + expect($openapi)->toContain('ErrorReportStatusUpdateRequest'); +}); diff --git a/services/nginx/app/tests/Unit/Http/ResponseRequestParametersTest.php b/services/nginx/app/tests/Unit/Http/ResponseRequestParametersTest.php new file mode 100644 index 00000000..969a6d3d --- /dev/null +++ b/services/nginx/app/tests/Unit/Http/ResponseRequestParametersTest.php @@ -0,0 +1,71 @@ +body; + } + }; +} + +it('reads JSON payloads for DELETE request parameter arrays', function (): void { + $previousMethod = $_SERVER['REQUEST_METHOD'] ?? null; + $previousGet = $_GET; + + try { + $_SERVER['REQUEST_METHOD'] = 'DELETE'; + $_GET = []; + + $response = response_request_parameters_response_with_body(json_encode([ + 'confirm' => 'delete-coolify-target-3', + 'delete_resource' => false, + ])); + + expect($response->getAllRequestParameters())->toBe([ + 'confirm' => 'delete-coolify-target-3', + 'delete_resource' => false, + ]); + expect($response->getRequestParameter('confirm'))->toBe('delete-coolify-target-3'); + expect($response->isRequestParameterSet('delete_resource'))->toBeTrue(); + } finally { + $_GET = $previousGet; + if ($previousMethod === null) { + unset($_SERVER['REQUEST_METHOD']); + } else { + $_SERVER['REQUEST_METHOD'] = $previousMethod; + } + } +}); + +it('keeps DELETE query parameters when no JSON body is present', function (): void { + $previousMethod = $_SERVER['REQUEST_METHOD'] ?? null; + $previousGet = $_GET; + + try { + $_SERVER['REQUEST_METHOD'] = 'DELETE'; + $_GET = ['confirm' => 'delete-coolify-target-5']; + + $response = response_request_parameters_response_with_body(''); + + expect($response->getAllRequestParameters())->toBe([ + 'confirm' => 'delete-coolify-target-5', + ]); + } finally { + $_GET = $previousGet; + if ($previousMethod === null) { + unset($_SERVER['REQUEST_METHOD']); + } else { + $_SERVER['REQUEST_METHOD'] = $previousMethod; + } + } +}); diff --git a/services/nginx/app/tests/Unit/Infrastructure/CorsPolicyTest.php b/services/nginx/app/tests/Unit/Infrastructure/CorsPolicyTest.php new file mode 100644 index 00000000..b452177c --- /dev/null +++ b/services/nginx/app/tests/Unit/Infrastructure/CorsPolicyTest.php @@ -0,0 +1,66 @@ +toBe('https://api-v2.truckwash.io'); + expect(cors_policy::normalizeOrigin('https://API-V2.TRUCKWASH.IO/canary/api/')) + ->toBe('https://api-v2.truckwash.io'); + expect(cors_policy::normalizeOrigin('https://api.truckwash.io:4433/ping')) + ->toBe('https://api.truckwash.io:4433'); +}); + +it('merges required release and existing frontend origins into configured CORS', function (): void { + $origins = cors_policy::allowedOrigins('https://example.test/app,https://api-v2.truckwash.io/master/api'); + + expect($origins)->toContain('https://api-v2.truckwash.io'); + expect($origins)->toContain('http://localhost:5173'); + expect($origins)->toContain('https://truckwash.io'); + expect($origins)->not->toContain('https://api-v2.truckwash.io/master/api'); +}); + +it('builds credential-safe normal CORS response headers for allowed origins', function (): void { + $headers = cors_policy::responseHeaders('http://localhost:5173', 'https://truckwash.io'); + + expect($headers['Access-Control-Allow-Origin'])->toBe('http://localhost:5173'); + expect($headers['Access-Control-Allow-Credentials'])->toBe('true'); + expect($headers['Access-Control-Allow-Methods'])->toContain('PATCH'); + expect($headers['Access-Control-Allow-Headers'])->toContain('X-Release-Trace'); + expect($headers['Access-Control-Allow-Headers'])->toContain('Cache-Control'); + expect($headers['Access-Control-Max-Age'])->toBe('86400'); + expect($headers['Vary'])->toBe('Origin'); +}); + +it('builds preflight CORS response headers for api-v2 release URLs', function (): void { + $preflight = cors_policy::preflightResponse( + 'https://api-v2.truckwash.io/master/api', + 'https://truckwash.io' + ); + + expect($preflight['allowed'])->toBeTrue(); + expect($preflight['status'])->toBe(200); + expect($preflight['headers']['Access-Control-Allow-Origin'])->toBe('https://api-v2.truckwash.io'); + expect($preflight['headers']['Content-Type'])->toBe('application/json'); + expect($preflight['body'])->toBe(''); +}); + +it('rejects unknown CORS origins', function (): void { + $headers = cors_policy::responseHeaders('https://evil.example.test', 'https://truckwash.io'); + $preflight = cors_policy::preflightResponse('https://evil.example.test', 'https://truckwash.io'); + + expect($headers)->toBe([]); + expect($preflight['allowed'])->toBeFalse(); + expect($preflight['status'])->toBe(403); + expect($preflight['body'])->toContain('CORS origin not allowed'); +}); + +it('reflects the request origin for wildcard CORS instead of sending credentialed wildcard headers', function (): void { + $headers = cors_policy::responseHeaders('https://partner.example.test', '*'); + + expect($headers['Access-Control-Allow-Origin'])->toBe('https://partner.example.test'); + expect($headers['Access-Control-Allow-Credentials'])->toBe('true'); + expect($headers['Access-Control-Allow-Origin'])->not->toBe('*'); +}); diff --git a/services/nginx/app/tests/Unit/Infrastructure/CorsReleaseHeadersTest.php b/services/nginx/app/tests/Unit/Infrastructure/CorsReleaseHeadersTest.php new file mode 100644 index 00000000..1f148c02 --- /dev/null +++ b/services/nginx/app/tests/Unit/Infrastructure/CorsReleaseHeadersTest.php @@ -0,0 +1,28 @@ +toBeTrue(); + $content = (string)file_get_contents($file); + + foreach ($requiredHeaders as $header) { + expect($content)->toContain($header); + } + } + + expect((string)file_get_contents(app_path('index.php')))->toContain('cors_policy::preflightResponse'); + expect((string)file_get_contents(app_path('routes/optionsRoute.php')))->toContain('cors_policy::preflightResponse'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueDetailsSummaryBuilderTest.php b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueDetailsSummaryBuilderTest.php index 1e82654c..48abec37 100644 --- a/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueDetailsSummaryBuilderTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueDetailsSummaryBuilderTest.php @@ -82,6 +82,25 @@ it('prefers queue error messages for failed jobs and keeps null-safe outcome fie ]); }); +it('uses queued job payload customer context before result data exists', function (): void { + $summary = economic_transfer_queue_details_summary::buildCollectedInvoiceSummary([ + 'status' => economic_transfer_queue::STATUS_QUEUED, + 'payload' => [ + 'collected_invoice_id' => 17389, + 'customer_number' => '778899', + 'customer_name' => 'Queued Customer A/S', + ], + 'result' => null, + 'progress_message' => 'Queued', + ]); + + expect($summary['target']['collected_invoice_id'])->toBe(17389); + expect($summary['customer'])->toBe([ + 'customer_number' => 778899, + 'name' => 'Queued Customer A/S', + ]); +}); + it('uses deterministic status message fallback when no explicit message exists', function (): void { $expectations = [ [economic_transfer_queue::STATUS_QUEUED, 'Queued'], diff --git a/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueRouteHardeningTest.php b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueRouteHardeningTest.php index 55c85098..51cb9ab0 100644 --- a/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueRouteHardeningTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceQueueRouteHardeningTest.php @@ -60,11 +60,44 @@ it('enforces retry constraints for collected-invoice queue jobs before retry exe expect($content)->toContain("Collected invoice queue job can only be retried when status is FAILED', 409"); expect($content)->toContain('private function withCollectedInvoiceQueueDetailsSummary(array $job): array'); expect($content)->toContain('private function withCollectedInvoiceQueueDetailsSummaryList(array $jobs): array'); + expect($content)->toContain('private function resolveCollectedInvoiceQueueCustomerSummary(array $customer, int $collected_invoice_id): array'); + expect($content)->toContain('FROM collected_order_invoices coi'); + expect($content)->toContain('LEFT JOIN users u ON u.customer_number = coi.customer_number'); expect($content)->toContain('private function resolveCollectedInvoiceQueueRetryErrorStatus(string $message): int'); expect($content)->toContain('only failed jobs can be retried'); expect($content)->toContain('max retry attempts'); }); +it('exposes collected-invoice queue monitor and per-user terminal clear routes', function (): void { + $content = file_get_contents(app_path('routes/orderInvoicesRoute.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + $monitorStart = strpos($content, "\$this->get('/collected-invoices/economic/queue/monitor'"); + $dismissStart = strpos($content, "\$this->post('/collected-invoices/economic/queue/dismiss'"); + $dismissTerminalStart = strpos($content, "\$this->post('/collected-invoices/economic/queue/dismiss-terminal'"); + + expect($monitorStart)->not->toBeFalse(); + expect($dismissStart)->not->toBeFalse(); + expect($dismissTerminalStart)->not->toBeFalse(); + + expect($content)->toContain('$limit = $this->parseCollectedInvoiceQueueMonitorLimit();'); + expect($content)->toContain('$this->buildCollectedInvoiceQueueMonitorPayload('); + expect($content)->toContain('private function parseCollectedInvoiceQueueMonitorLimit(): int'); + expect($content)->toContain('limit must be between 1 and 100'); + expect($content)->toContain('private function buildCollectedInvoiceQueueMonitorPayload(economic_transfer_queue $queue, int $user_id, int $limit): array'); + expect($content)->toContain('$queue->listMonitorJobsForUser('); + expect($content)->toContain("'queued' => 0"); + expect($content)->toContain("'in_progress' => 0"); + expect($content)->toContain("'progress_percent' => \$counts['total'] > 0"); + + expect($content)->toContain('$queue->dismissTerminalJobForUser($job_id, (int)$user->id);'); + expect($content)->toContain('Only completed or failed collected invoice queue jobs can be cleared'); + expect($content)->toContain('$queue->dismissTerminalJobsForUser('); + expect($content)->toContain("'dismissed_count' => \$dismissed_count"); +}); + it('runs collected-invoice queue batches through an explicit manual endpoint', function (): void { $content = file_get_contents(app_path('routes/orderInvoicesRoute.php')); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicAuthTokenFallbackTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicAuthTokenFallbackTest.php index 47ef90f3..08f5a822 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicAuthTokenFallbackTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicAuthTokenFallbackTest.php @@ -176,3 +176,17 @@ it('returns raw payload unchanged on successful HTTP statuses', function (): voi expect($endpointProbe->assertSuccessfulResponse(200, $payload))->toBe($payload); expect($legacyProbe->assertSuccessfulResponse(200, $payload))->toBe($payload); }); + +it('bounds e-conomic curl calls below the PHP request timeout', function (): void { + $legacyContent = file_get_contents(app_path('modules/economic/economic_m.php')); + $endpointContent = file_get_contents(app_path('traits/economic_endpoint_t.php')); + + expect($legacyContent)->not->toBeFalse() + ->and($endpointContent)->not->toBeFalse(); + + foreach ([(string)$legacyContent, (string)$endpointContent] as $content) { + expect($content)->toContain('CURLOPT_CONNECTTIMEOUT => 3') + ->and($content)->toContain('CURLOPT_TIMEOUT => 30') + ->and($content)->not->toContain('CURLOPT_TIMEOUT => 0'); + } +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAvailabilityGuardTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAvailabilityGuardTest.php index d64b8fc7..733752a0 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAvailabilityGuardTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAvailabilityGuardTest.php @@ -61,12 +61,15 @@ it('keeps queue status endpoints guarded while collected invoice export routes s $guard_count = preg_match_all('/\$this->ensureEconomicTransferQueueIsAvailable\(\);/', (string)$content); $queue_init_count = preg_match_all('/new economic_transfer_queue\(\);/', (string)$content); - expect($guard_count)->toBe(4); - expect($queue_init_count)->toBe(6); + expect($guard_count)->toBe(7); + expect($queue_init_count)->toBe(9); $queue_endpoint_patterns = [ "/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue\\/monitor'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", "/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue\\/status'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/dismiss'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/dismiss-terminal'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", "/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/retry'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", "/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/run'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", ]; diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueHardeningTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueHardeningTest.php index 46a28e95..f94bb759 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueHardeningTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueHardeningTest.php @@ -18,6 +18,13 @@ it('hardens transfer queue with type validation retry caps and stale lock recove expect($content)->toContain('AND attempts < max_attempts'); expect($content)->toContain('public function listJobs(array $statuses = [], int $limit = 50, int $offset = 0, ?string $transfer_type = null): array'); expect($content)->toContain('public function countJobs(array $statuses = [], ?string $transfer_type = null): int'); + expect($content)->toContain('public function listMonitorJobsForUser(int $user_id, int $limit = 50, ?string $transfer_type = null): array'); + expect($content)->toContain('public function dismissTerminalJobForUser(int $job_id, int $user_id): array'); + expect($content)->toContain('public function dismissTerminalJobsForUser(int $user_id, ?string $transfer_type = null): int'); + expect($content)->toContain('economic_transfer_queue_job_dismissals'); + expect($content)->toContain("Only completed or failed queue jobs can be dismissed"); + expect($content)->toContain('$this->clearDismissalsForJob($job_id);'); + expect($content)->toContain('private function clearDismissalsForJob(int $job_id): void'); expect($content)->toContain('public function processPendingByTransferType(string $transfer_type, int $limit = 10): array'); expect($content)->toContain('private function processPendingInternal(int $limit = 5, ?string $transfer_type = null): array'); expect($content)->toContain('private function claimNextJob(?string $transfer_type = null): ?array'); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueOpenApiSpecTest.php index 39b2a31e..8e68b0dc 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueOpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueOpenApiSpecTest.php @@ -43,8 +43,11 @@ it('documents economic transfer queue paths in openapi', function (): void { expect($content)->toContain('/collected-invoices/economic:'); expect($content)->toContain('/collected-invoices/stripe/book:'); expect($content)->toContain('/collected-invoices/economic/queue:'); + expect($content)->toContain('/collected-invoices/economic/queue/monitor:'); expect($content)->toContain('/collected-invoices/economic/queue/status:'); expect($content)->toContain('/collected-invoices/economic/queue/retry:'); + expect($content)->toContain('/collected-invoices/economic/queue/dismiss:'); + expect($content)->toContain('/collected-invoices/economic/queue/dismiss-terminal:'); expect($content)->toContain('/collected-invoices/economic/queue/run:'); expect($content)->toContain('/economic/invoice/draft/export:'); expect($content)->toContain('/economic/invoice/draft/export/status:'); @@ -65,6 +68,9 @@ it('documents economic transfer queue schemas in openapi', function (): void { expect($content)->toContain('EconomicTransferQueueRetryResponse:'); expect($content)->toContain('EconomicTransferQueueRunResponse:'); expect($content)->toContain('EconomicTransferQueueListResponse:'); + expect($content)->toContain('EconomicTransferQueueMonitorResponse:'); + expect($content)->toContain('EconomicTransferQueueDismissResponse:'); + expect($content)->toContain('EconomicTransferQueueDismissTerminalResponse:'); }); it('documents 200 fallback plus 202 queue contracts for export endpoints and keeps 503 on queue lifecycle endpoints', function (): void { @@ -101,9 +107,12 @@ it('documents 200 fallback plus 202 queue contracts for export endpoints and kee } $queue_blocks = [ - economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue:', '/collected-invoices/economic/queue/status:'), + economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue:', '/collected-invoices/economic/queue/monitor:'), + economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/monitor:', '/collected-invoices/economic/queue/status:'), economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/status:', '/collected-invoices/economic/queue/retry:'), - economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/retry:', '/collected-invoices/economic/queue/run:'), + economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/retry:', '/collected-invoices/economic/queue/dismiss:'), + economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/dismiss:', '/collected-invoices/economic/queue/dismiss-terminal:'), + economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/dismiss-terminal:', '/collected-invoices/economic/queue/run:'), economic_transfer_queue_openapi_block($content, '/collected-invoices/economic/queue/run:', '/collected-invoices/economic/compare:'), economic_transfer_queue_openapi_block($content, '/economic/invoice/draft/export/status:', '/economic/invoice/draft/export/retry:'), economic_transfer_queue_openapi_block($content, '/economic/invoice/export/status:', '/economic/invoice/export/retry:'), @@ -120,7 +129,7 @@ it('documents additive collected-invoice queue pagination metadata and retry con $queue_list_block = economic_transfer_queue_openapi_block( $content, '/collected-invoices/economic/queue:', - '/collected-invoices/economic/queue/status:' + '/collected-invoices/economic/queue/monitor:' ); expect($queue_list_block)->toContain('style: form'); expect($queue_list_block)->toContain('explode: false'); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php index 1cf753c9..b2bb7d0f 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php @@ -10,6 +10,11 @@ it('defines economic transfer queue jobs schema bootstrap table and tracking col expect($content)->toContain('error_message TEXT NULL'); expect($content)->toContain('payload_json JSON NOT NULL'); expect($content)->toContain('result_json JSON NULL'); + expect($content)->toContain('CREATE TABLE IF NOT EXISTS economic_transfer_queue_job_dismissals'); + expect($content)->toContain('queue_job_id BIGINT UNSIGNED NOT NULL'); + expect($content)->toContain('user_id INT NOT NULL'); + expect($content)->toContain('dismissed_status VARCHAR(32) NOT NULL'); + expect($content)->toContain('PRIMARY KEY (queue_job_id, user_id)'); }); it('provides queue processor class constants and processing entrypoint', function (): void { diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2BookedDepartment75DistributionTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2BookedDepartment75DistributionTest.php index bbe56abe..b0abbaac 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicV2BookedDepartment75DistributionTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2BookedDepartment75DistributionTest.php @@ -53,6 +53,8 @@ if (!class_exists('TestableEconomicV2BookedDepartment75DistributionService')) { public array $subscriptionPrices = []; public array $stubBookedInvoices = []; public array $stubBookedInvoiceLines = []; + public int $fallbackDepartmentId = 8; + public array $customerDefaultDepartments = []; public ?array $includedCustomers = null; public function __construct(?economic_v2_versioning_service $versioning = null) @@ -138,6 +140,16 @@ if (!class_exists('TestableEconomicV2BookedDepartment75DistributionService')) { return in_array($customer_number, $this->includedCustomers, true); } + protected function getCustomerDefaultDepartmentId(int $customer_number): ?int + { + return $this->customerDefaultDepartments[$customer_number] ?? null; + } + + protected function getFallbackDistributionDepartmentId(): int + { + return $this->fallbackDepartmentId; + } + protected function parseDepartmentMap(array $department_map): array { $parsed = []; @@ -303,7 +315,7 @@ it('redistributes booked department 75 net amounts using fixed pricing and wash expect($result['collective_results']['department_distribution']['2'])->toBe(138.46154); }); -it('keeps classified booked department 75 amounts undistributed when no monthly basis exists', function (): void { +it('assigns classified booked department 75 amounts to fallback when no monthly basis exists', function (): void { $service = new TestableEconomicV2BookedDepartment75DistributionService(new FakeEconomicV2BookedDepartment75VersioningService()); $service->stubBookedInvoices = [[ 'bookedInvoiceNumber' => 7002, @@ -336,11 +348,18 @@ it('keeps classified booked department 75 amounts undistributed when no monthly $result = $service->getBookedDepartment75Distribution('2026-01-01', '2026-01-31'); expect($result['customers'])->toHaveCount(1); - expect($result['customers'][0]['meta']['booked_department_75']['booked_groups'][0]['source_category'])->toBe('fixed_pricing'); - expect($result['customers'][0]['meta']['booked_department_75']['distributed_net_amount'])->toBe(0.0); - expect($result['customers'][0]['meta']['booked_department_75']['undistributed_net_amount'])->toBe(60.0); - expect($result['collective_results']['undistributed_net_amount'])->toBe(60.0); + $meta = $result['customers'][0]['meta']['booked_department_75']; + expect($meta['booked_groups'][0]['source_category'])->toBe('fixed_pricing'); + expect($meta['distributed_net_amount'])->toBe(60.0); + expect($meta['undistributed_net_amount'])->toBe(0.0); + expect($meta['department_distribution']['8'])->toBe(60.0); + expect($meta['booked_groups'][0]['department_distribution']['8'])->toBe(60.0); + expect($meta['booked_groups'][0]['undistributed_net_amount'])->toBe(0.0); + expect($result['collective_results']['distributed_net_amount'])->toBe(60.0); + expect($result['collective_results']['undistributed_net_amount'])->toBe(0.0); + expect($result['collective_results']['department_distribution']['8'])->toBe(60.0); expect(implode("\n", $result['warnings']))->toContain('has no redistribution basis'); + expect(implode("\n", $result['warnings']))->toContain('assigned to fallback department 8'); }); it('keeps unclassified booked department 75 lines undistributed with warnings', function (): void { diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceFallbackTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceFallbackTest.php index 918e5d2f..4bb02608 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceFallbackTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicV2DistributionServiceFallbackTest.php @@ -54,6 +54,8 @@ if (!class_exists('TestableEconomicV2DistributionService')) { public array $stubVersionRows = []; public array $subscriptionPrices = []; public array $versionTableState = []; + public int $fallbackDepartmentId = 8; + public array $customerDefaultDepartments = []; public ?array $includedCustomers = null; public function __construct(?economic_v2_versioning_service $versioning = null) @@ -138,6 +140,16 @@ if (!class_exists('TestableEconomicV2DistributionService')) { return in_array($customer_number, $this->includedCustomers, true); } + protected function getCustomerDefaultDepartmentId(int $customer_number): ?int + { + return $this->customerDefaultDepartments[$customer_number] ?? null; + } + + protected function getFallbackDistributionDepartmentId(): int + { + return $this->fallbackDepartmentId; + } + protected function parseDepartmentMap(array $department_map): array { $parsed = []; @@ -198,7 +210,7 @@ it('runs best-effort backfill when fixed pricing version history is empty', func expect($result['customers'])->toHaveCount(1); }); -it('falls back to system orders for fixed pricing when regular orders yield no customers', function (): void { +it('falls back to system orders for fixed pricing using the configured fallback department', function (): void { $versioning = new FakeEconomicV2DistributionVersioningService(); $versioning->fixedVersion = [ 'id' => 91, @@ -235,11 +247,14 @@ it('falls back to system orders for fixed pricing when regular orders yield no c expect($result['customers'][0]['customer_number'])->toBe(12345); expect($result['customers'][0]['meta']['fixed_pricing']['price'])->toBe(499.95); expect($result['customers'][0]['meta']['fixed_pricing']['version_groups'][0]['order_ids'])->toBe([501]); + expect($result['customers'][0]['meta']['fixed_pricing']['version_groups'][0]['department_totals']['8'])->toBe(499.95); + expect($result['customers'][0]['meta']['fixed_pricing']['version_groups'][0]['department_totals'])->not->toHaveKey('10'); + expect($result['collective_results']['total_department_totals']['8'])->toBe(499.95); expect($result['collective_results']['total_fixed_price'])->toBe(499.95); expect($result['warnings'])->toContain('System order fallback used for fixed pricing (department 10).'); }); -it('falls back to system orders for wash subscriptions when regular orders yield no customers', function (): void { +it('falls back to system orders for wash subscriptions using the configured fallback department', function (): void { $versioning = new FakeEconomicV2DistributionVersioningService(); $versioning->subscriptionVersions = [[ 'id' => 42, @@ -277,11 +292,41 @@ it('falls back to system orders for wash subscriptions when regular orders yield expect($result['customers'][0]['customer_number'])->toBe(12345); expect($result['customers'][0]['meta']['subscription']['subscription_total'])->toBe(299.0); expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['reg'])->toBe('AB12345'); - expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['department_distribution']['10'])->toBe(299.0); + expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['department_distribution']['8'])->toBe(299.0); + expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['department_distribution'])->not->toHaveKey('10'); + expect($result['collective_results']['subscription_price_department_distribution']['8'])->toBe(299.0); expect($result['collective_results']['total_subscription_price'])->toBe(299.0); expect($result['warnings'])->toContain('System order fallback used for wash subscriptions (department 10).'); }); +it('uses the configured fallback department when a subscription has no customer department basis', function (): void { + $versioning = new FakeEconomicV2DistributionVersioningService(); + + $service = new TestableEconomicV2DistributionService($versioning); + $service->subscriptionPrices = [ + 77 => 299.0, + ]; + $service->stubVersionRows = [[ + 'id' => 42, + 'customer_number' => 12345, + 'reg' => 'AB12345', + 'vehicle_type' => 77, + 'wash_subscription' => 1, + 'source' => 'test.version', + 'confidence' => 1.0, + 'inferred' => false, + 'effective_from' => '2026-01-01 00:00:00', + 'effective_to' => null, + ]]; + + $result = $service->getWashSubscriptionsDistribution('2026-01-01', '2026-01-31'); + + expect($result['customers'])->toHaveCount(1); + expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['department_distribution']['8'])->toBe(299.0); + expect($result['customers'][0]['meta']['subscription']['version_groups'][0]['department_distribution'])->not->toHaveKey('1'); + expect($result['collective_results']['subscription_price_department_distribution']['8'])->toBe(299.0); +}); + it('excludes orphaned customer traces from fixed pricing and customer price distributions', function (): void { $versioning = new FakeEconomicV2DistributionVersioningService(); $versioning->fixedVersion = [ diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php new file mode 100644 index 00000000..dfae664f --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php @@ -0,0 +1,978 @@ +newInstanceWithoutConstructor(); + return $service; +} + +function invoice_period_flag_service_invoke(string $method, array $args = []): mixed +{ + $service = invoice_period_flag_service_instance(); + $reflection = new ReflectionClass(invoice_period_flag_service::class); + $target = $reflection->getMethod($method); + $target->setAccessible(true); + return $target->invokeArgs($service, $args); +} + +it('builds deterministic automatic flag fingerprints and interactive price message parts', function (): void { + $row = [ + 'customer_number' => 424242, + 'customer_name' => 'Flagged Customer', + 'order_id' => 9001, + 'order_item_id' => 7001, + 'invoice_collection_id' => 3001, + ]; + $params = [ + 'product' => 'Spot Free', + 'expected_price' => 81, + 'actual_price' => 99, + ]; + $context = [ + 'department_id' => 1, + 'order_id' => 9001, + 'order_item_id' => 7001, + ]; + + $flag = invoice_period_flag_service_invoke('automaticFlag', [ + 'price_mismatch', + 'order_item_field', + 7001, + 'price', + $row, + $params, + $context, + ]); + $sameFlag = invoice_period_flag_service_invoke('automaticFlag', [ + 'price_mismatch', + 'order_item_field', + 7001, + 'price', + $row, + $params, + $context, + ]); + $changedPriceFlag = invoice_period_flag_service_invoke('automaticFlag', [ + 'price_mismatch', + 'order_item_field', + 7001, + 'price', + $row, + [ + ...$params, + 'actual_price' => 100, + ], + $context, + ]); + + expect($flag['fingerprint'])->toBe($sameFlag['fingerprint']); + expect($flag['fingerprint'])->not->toBe($changedPriceFlag['fingerprint']); + expect($flag['id'])->toBe('auto:' . $flag['fingerprint']); + expect($flag['message_key'])->toBe('invoice_period.flags.automatic.price_mismatch'); + expect($flag['message'])->toBe('Spot Free product price differs from expected.'); + expect($flag['message_parts'])->toBe([ + ['type' => 'order_item', 'text' => 'Spot Free'], + ['type' => 'text', 'text' => ' product price differs from '], + ['type' => 'expected_price', 'text' => 'expected'], + ['type' => 'text', 'text' => '.'], + ]); +}); + +it('builds interactive message parts for order and wash certificate warnings', function (): void { + $row = [ + 'customer_number' => 424242, + 'customer_name' => 'Flagged Customer', + 'order_id' => 9001, + 'order_item_id' => 7001, + 'invoice_collection_id' => 3001, + ]; + + $orderFlag = invoice_period_flag_service_invoke('automaticFlag', [ + 'multiple_identical_primary_vehicle_items', + 'order', + 9001, + null, + $row, + [], + ['department_id' => 1, 'order_id' => 9001], + ]); + $washCertificateFlag = invoice_period_flag_service_invoke('automaticFlag', [ + 'wash_certificate_item_without_certificate', + 'order_item', + 7001, + null, + $row, + [], + ['department_id' => 1, 'order_id' => 9001, 'order_item_id' => 7001], + ]); + $xlVaskFlag = invoice_period_flag_service_invoke('automaticFlag', [ + 'xlvask_missing_order_link', + 'xlvask_usage_log', + 55, + null, + [ + 'customer_number' => 424242, + 'customer_name' => 'Flagged Customer', + 'xlvask_usage_log_id' => 55, + ], + [ + 'wash_id' => 'wash-55', + 'registration_number' => 'AB12345', + ], + [ + 'customer_number' => 424242, + 'customer_name' => 'Flagged Customer', + 'xlvask_usage_log_id' => 55, + 'wash_id' => 'wash-55', + 'registration_number' => 'AB12345', + 'start_time' => '2026-05-11 10:00:00', + ], + ]); + + expect($orderFlag['message_parts'])->toBe([ + ['type' => 'order', 'text' => 'Order'], + ['type' => 'text', 'text' => ' contains multiple identical primary vehicle items.'], + ]); + expect($washCertificateFlag['message_parts'])->toBe([ + ['type' => 'order_item', 'text' => 'Wash certificate item'], + ['type' => 'text', 'text' => ' is present without a wash certificate.'], + ]); + expect($xlVaskFlag['message_parts'])->toBe([ + ['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'], + ['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'], + ]); +}); + +it('includes order item preview context for required order field warnings', function (): void { + global $db; + + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $GLOBALS['db'] ?? null; + $db = new class { + public array $queries = []; + + public function query(string $sql): object|false + { + $this->queries[] = $sql; + + if (str_contains($sql, 'FROM order_items')) { + return $this->result([ + [ + 'id' => 91, + 'order_id' => 61415, + 'product_id' => 3, + 'reference' => '', + 'notes' => '', + 'price' => 649, + 'quantity' => 1, + 'related_item_id' => 0, + 'product_name' => 'Forvogn', + 'product_base_price' => 649, + ], + [ + 'id' => 92, + 'order_id' => 61415, + 'product_id' => 4, + 'reference' => '', + 'notes' => '', + 'price' => 599, + 'quantity' => 1, + 'related_item_id' => 0, + 'product_name' => 'Trailer', + 'product_base_price' => 599, + ], + ]); + } + + return false; + } + + public function fetch_all(object $result): array + { + return $result->rows; + } + + private function result(array $rows): object + { + return new class($rows) { + public int $num_rows; + public array $rows; + + public function __construct(array $rows) + { + $this->rows = $rows; + $this->num_rows = count($rows); + } + + public function fetch_assoc(): ?array + { + return array_shift($this->rows); + } + }; + } + }; + + try { + $row = [ + 'customer_number' => 424242, + 'customer_name' => 'Flagged Customer', + 'order_id' => 61415, + 'order_item_id' => 91, + 'invoice_collection_id' => 3001, + 'department_id' => 5, + 'order_reference' => '', + 'order_po' => '', + 'reg_1' => 'EC21233', + ]; + + $flags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [ + [$row], + [424242 => ['requiresReferenceNumber' => true, 'usePONumbers' => true]], + ]); + $byDefinition = []; + foreach ($flags as $flag) { + $byDefinition[$flag['definition_key']] = $flag; + } + + expect($byDefinition['customer_rule_requires_reference'] ?? null)->not->toBeNull(); + expect($byDefinition['customer_rule_requires_po_number'] ?? null)->not->toBeNull(); + expect($byDefinition['customer_rule_requires_reference']['context']['order_items'])->toHaveCount(2); + expect($byDefinition['customer_rule_requires_reference']['context']['order_items'][0]['product_name'])->toBe('Forvogn'); + expect($byDefinition['customer_rule_requires_po_number']['context']['order_items'][1]['product_name'])->toBe('Trailer'); + } finally { + if ($hadDb) { + $db = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); + +it('allows tank cleaning products for only tank cleaning customers', function (): void { + $baseRow = [ + 'customer_number' => 424242, + 'customer_name' => 'Tank Customer', + 'order_id' => 61426, + 'invoice_collection_id' => 3090, + 'department_id' => 5, + 'related_item_id' => 0, + 'item_price' => 100, + 'order_reference' => 'REF', + 'order_po' => 'PO', + 'reg_1' => 'NI465', + ]; + $tankCleaningRow = $baseRow + [ + 'order_item_id' => 801, + 'product_id' => 30, + 'product_name' => 'Tank cleaning 4 spulehoveder', + 'product_category' => 5, + 'category_name' => 'Tank cleaning', + ]; + $tankCleaningAddonRow = $baseRow + [ + 'order_item_id' => 802, + 'product_id' => 33, + 'product_name' => 'Saebe/kemi, 1-4 spulehoveder', + 'product_category' => 5, + 'category_name' => 'Tank cleaning', + ]; + $washRow = $baseRow + [ + 'order_item_id' => 803, + 'product_id' => 3, + 'product_name' => 'Forvogn', + 'product_category' => 1, + 'category_name' => 'Vask', + ]; + + $onlyTankCleaningFlags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [ + [$tankCleaningRow, $tankCleaningAddonRow, $washRow], + [424242 => ['onlyTankCleaning' => true]], + ]); + + expect(array_column($onlyTankCleaningFlags, 'definition_key'))->toBe(['customer_rule_only_tank_cleaning']); + expect($onlyTankCleaningFlags[0]['target_id'])->toBe(803); + + $restrictedTankCleaningFlags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [ + [$tankCleaningRow, $washRow], + [424242 => ['restrictTankCleaning' => true]], + ]); + + expect(array_column($restrictedTankCleaningFlags, 'definition_key'))->toBe(['customer_rule_restrict_tank_cleaning']); + expect($restrictedTankCleaningFlags[0]['target_id'])->toBe(801); +}); + +it('does not flag interior wash variants as historical primary product mismatches', function (): void { + global $db; + + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $GLOBALS['db'] ?? null; + $db = new class { + public function escape_string(string $value): string + { + return addslashes($value); + } + + public function query(string $sql): object|false + { + if (str_contains($sql, 'SHOW COLUMNS FROM `customer_vehicles`')) { + return $this->result([]); + } + + if (str_contains($sql, 'FROM customer_vehicles')) { + return $this->result([]); + } + + if (str_contains($sql, 'FROM orders o') && str_contains($sql, 'GROUP BY UPPER(TRIM(o.reg_1))')) { + return $this->result([ + [ + 'reg' => 'CN96636', + 'product_id' => 3, + 'product_name' => 'Forvogn', + 'usage_count' => 5, + ], + ]); + } + + return false; + } + + private function result(array $rows): object + { + return new class($rows) { + public int $num_rows; + public array $rows; + + public function __construct(array $rows) + { + $this->rows = $rows; + $this->num_rows = count($rows); + } + + public function fetch_assoc(): ?array + { + return array_shift($this->rows); + } + }; + } + }; + + try { + $baseRow = [ + 'customer_number' => 424242, + 'customer_name' => 'History Customer', + 'order_id' => 61311, + 'order_item_id' => 901, + 'invoice_collection_id' => 16912, + 'department_id' => 7, + 'reg_1' => 'CN96636', + 'is_wash' => 1, + 'related_item_id' => 0, + 'order_created_at' => '2026-05-11 08:05:21', + ]; + + $interiorVariantFlags = invoice_period_flag_service_invoke('detectVehicleTypeMismatches', [ + [ + $baseRow + [ + 'product_id' => 99, + 'product_name' => 'Indvendig vask Forvogn', + ], + ], + '2026-05-11 00:00:00', + ]); + + expect($interiorVariantFlags)->toBe([]); + + $mismatchFlags = invoice_period_flag_service_invoke('detectVehicleTypeMismatches', [ + [ + $baseRow + [ + 'product_id' => 17, + 'product_name' => 'Bus', + ], + ], + '2026-05-11 00:00:00', + ]); + + expect(array_column($mismatchFlags, 'definition_key'))->toBe(['historical_primary_product_mismatch']); + } finally { + if ($hadDb) { + $db = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); + +it('does not report duplicate primary vehicle products from duplicated detector rows for the same order item', function (): void { + $row = [ + 'customer_number' => 424242, + 'customer_name' => 'Flagged Customer', + 'order_id' => 9001, + 'order_item_id' => 7001, + 'product_id' => 3, + 'product_name' => 'Forvogn', + 'category_name' => 'Vask', + 'is_wash' => 1, + 'related_item_id' => 0, + 'item_quantity' => 1, + 'max_quantity_per_order' => null, + 'safety_seal' => '', + 'order_created_at' => '2026-05-11 10:00:00', + 'reg_1' => 'AB12345', + ]; + + $flags = invoice_period_flag_service_invoke('detectAbnormalQuantities', [ + [$row, $row], + '2026-05-11 00:00:00', + '2026-05-11 23:59:59', + ]); + + expect(array_column($flags, 'definition_key'))->not->toContain('multiple_identical_primary_vehicle_items'); +}); + +it('uses attached wash certificate documents instead of safety seal text for certificate presence', function (): void { + $row = [ + 'customer_number' => 424242, + 'customer_name' => 'Flagged Customer', + 'order_id' => 9001, + 'order_item_id' => 7001, + 'product_id' => 41, + 'product_name' => 'Vaskecertifikat - Safety Seal', + 'category_name' => 'Tillæg', + 'is_wash' => 0, + 'related_item_id' => 0, + 'item_quantity' => 1, + 'max_quantity_per_order' => null, + 'safety_seal' => '', + 'has_wash_certificate_attachment' => 1, + 'order_created_at' => '2026-05-11 10:00:00', + 'reg_1' => 'AB12345', + ]; + + $flags = invoice_period_flag_service_invoke('detectAbnormalQuantities', [ + [$row], + '2026-05-11 00:00:00', + '2026-05-11 23:59:59', + ]); + + expect(array_column($flags, 'definition_key'))->not->toContain('wash_certificate_item_without_certificate'); +}); + +it('loads wash certificate attachment presence from order attachment content', function (): void { + global $db; + + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $GLOBALS['db'] ?? null; + $db = new class { + public array $queries = []; + + public function escape_string(string $value): string + { + return addslashes($value); + } + + public function query(string $sql) + { + $this->queries[] = $sql; + + if (str_contains($sql, 'SHOW TABLES LIKE')) { + return $this->result([['table' => 'object_attachments']]); + } + + if (str_contains($sql, 'FROM object_attachments')) { + return $this->result([ + ['object_id' => 9001, 'content' => json_encode(['other' => 'WASH_CERTIFICATE'])], + ['object_id' => 9002, 'content' => json_encode(['other' => 'invoice'])], + ]); + } + + return false; + } + + private function result(array $rows): object + { + return new class($rows) { + public int $num_rows; + private array $rows; + + public function __construct(array $rows) + { + $this->rows = $rows; + $this->num_rows = count($rows); + } + + public function fetch_assoc(): ?array + { + return array_shift($this->rows); + } + }; + } + }; + + try { + $attached = invoice_period_flag_service_invoke('getWashCertificateAttachmentOrderIds', [[9001, 9002, 9001]]); + + expect($attached)->toBe([9001 => true]); + expect($db->queries[1])->toContain("object_type IN ('orders','`orders`')"); + expect($db->queries[1])->toContain('deleted_at IS NULL'); + } finally { + if ($hadDb) { + $db = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); + +it('uses the highest customer-specific discount in expected price breakdowns', function (): void { + $row = [ + 'customer_number' => 0, + 'product_base_price' => 150, + 'department_price' => 100, + 'product_discount_percentage' => 5, + 'category_discount_percentage' => 12, + 'apply_category_discount' => 1, + ]; + + $expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]); + $breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]); + + expect($expected)->toBe(88); + expect($breakdown)->toMatchArray([ + 'product_price' => 150, + 'department_price' => 100, + 'effective_base_price' => 100, + 'product_discount_percentage' => 5, + 'category_discount_percentage' => 12, + 'economic_customer_discount_percentage' => 0, + 'applied_discount_percentage' => 12, + 'expected_price' => 88, + ]); +}); + +it('uses a preloaded e-conomic global discount in expected price breakdowns', function (): void { + $service = invoice_period_flag_service_instance(); + $reflection = new ReflectionClass(invoice_period_flag_service::class); + + $cache = $reflection->getProperty('economicCustomerDiscountCache'); + $cache->setAccessible(true); + $cache->setValue($service, [ + 35131752 => 18, + ]); + + $calculate = $reflection->getMethod('calculateExpectedPrice'); + $calculate->setAccessible(true); + $breakdownMethod = $reflection->getMethod('priceBreakdown'); + $breakdownMethod->setAccessible(true); + + $row = [ + 'customer_number' => 35131752, + 'user_id' => 411, + 'product_base_price' => 100, + 'department_price' => null, + 'product_discount_percentage' => 5, + 'category_discount_percentage' => 12, + 'apply_category_discount' => 1, + ]; + + $expected = $calculate->invoke($service, $row); + $breakdown = $breakdownMethod->invoke($service, $row, $expected); + + expect($expected)->toBe(82); + expect($breakdown)->toMatchArray([ + 'effective_base_price' => 100, + 'product_discount_percentage' => 5, + 'category_discount_percentage' => 12, + 'economic_customer_discount_percentage' => 18, + 'applied_discount_percentage' => 18, + 'expected_price' => 82, + ]); +}); + +it('does not report a price mismatch when a product-specific discount makes the expected price zero', function (): void { + $row = [ + 'customer_number' => 35131752, + 'customer_name' => 'BHS Logistics A/S', + 'order_id' => 61359, + 'order_item_id' => 7701, + 'invoice_collection_id' => 16891, + 'department_id' => 1, + 'product_id' => 24, + 'product_name' => 'Spot Free- Lastbil', + 'product_base_price' => 39, + 'department_price' => null, + 'product_discount_percentage' => 100, + 'category_discount_percentage' => 0, + 'apply_category_discount' => 0, + 'item_price' => 0, + 'item_quantity' => 1, + 'item_include_in_invoice' => 1, + ]; + + $expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]); + $breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]); + $flags = invoice_period_flag_service_invoke('detectPriceMismatches', [[$row]]); + + expect($expected)->toBe(0); + expect($breakdown)->toMatchArray([ + 'product_price' => 39, + 'effective_base_price' => 39, + 'product_discount_percentage' => 100, + 'applied_discount_percentage' => 100, + 'expected_price' => 0, + ]); + expect($flags)->toBe([]); +}); + +it('preloads and caches missing e-conomic discounts before price mismatch detection', function (): void { + $content = file_get_contents(app_path('classes/invoice_period_flag_service.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('$this->preloadEconomicCustomerDiscounts($rows);'); + expect($content)->toContain('private function preloadEconomicCustomerDiscounts(array $rows): void'); + expect($content)->toContain("constant('redis')->get_economic_customer_discount_percentage(\$userId)"); + expect($content)->toContain('getCustomerDiscountPercentage($customerNumber)'); + expect($content)->toContain("constant('redis')->cache_economic_customer_discount_percentage(\$userId, \$discount)"); +}); + +it('seeds order item preview cache from period rows', function (): void { + $service = invoice_period_flag_service_instance(); + $reflection = new ReflectionClass(invoice_period_flag_service::class); + + $seed = $reflection->getMethod('seedOrderItemsPreviewCacheFromRows'); + $seed->setAccessible(true); + $preview = $reflection->getMethod('getOrderItemsForPreview'); + $preview->setAccessible(true); + + $seed->invoke($service, [ + [ + 'order_id' => 9001, + 'order_item_id' => 13, + 'product_id' => 102, + 'product_name' => 'Addon', + 'item_quantity' => 2, + 'item_price' => 25, + 'related_item_id' => 12, + ], + [ + 'order_id' => 9001, + 'order_item_id' => 12, + 'product_id' => 101, + 'product_name' => 'Wash', + 'item_quantity' => 1, + 'item_price' => 100, + 'related_item_id' => null, + ], + [ + 'order_id' => 9002, + 'order_item_id' => null, + ], + ]); + + expect($preview->invoke($service, 9001))->toBe([ + [ + 'id' => 12, + 'product_id' => 101, + 'product_name' => 'Wash', + 'quantity' => 1, + 'price' => 100, + ], + [ + 'id' => 13, + 'product_id' => 102, + 'product_name' => 'Addon', + 'quantity' => 2, + 'price' => 25, + ], + ])->and($preview->invoke($service, 9002))->toBe([]); +}); + +it('sorts manual flags before automatic warnings and preserves legacy circle indicators without flags', function (): void { + $manual = [ + 'id' => 12, + 'source' => 'manual', + 'status' => 'active', + 'created_at' => '2026-05-11 10:00:00', + ]; + $automatic = [ + 'id' => 'auto:abc', + 'source' => 'automatic', + 'status' => 'active', + 'fingerprint' => 'abc', + ]; + $resolvedManual = [ + 'id' => 13, + 'source' => 'manual', + 'status' => 'resolved', + 'created_at' => '2026-05-11 11:00:00', + ]; + $falsePositiveAutomatic = [ + 'id' => 'auto:def', + 'source' => 'automatic', + 'status' => 'false_positive', + 'fingerprint' => 'def', + ]; + + $flags = [$automatic, $manual]; + usort($flags, static fn(array $a, array $b): int => invoice_period_flag_service_invoke('sortFlags', [$a, $b])); + + expect($flags[0]['source'])->toBe('manual'); + expect(invoice_period_flag_service_invoke('countFlags', [$flags]))->toBe([ + 'manual' => 1, + 'automatic' => 1, + 'total' => 2, + ]); + expect(invoice_period_flag_service_invoke('countFlags', [[$resolvedManual, $falsePositiveAutomatic]]))->toBe([ + 'manual' => 0, + 'automatic' => 0, + 'total' => 0, + ]); + expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [['requires_action' => true], [$manual]])) + ->toBe('flag_red'); + expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [['requires_action' => true], [$automatic]])) + ->toBe('flag_yellow'); + expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [ + ['requires_action' => true], + [$resolvedManual, $falsePositiveAutomatic], + ]))->toBe('circle_red'); + expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [['requires_action' => true], []])) + ->toBe('circle_red'); + expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [['requires_action' => false], []])) + ->toBe('circle_green'); + expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [ + ['requires_action' => false, 'draft' => ['is_action_blocked' => true]], + [], + ]))->toBe('circle_yellow'); +}); + +it('scopes invoice period flags to the customer card that can render them', function (): void { + $customer = [ + 'customer_number' => 424242, + 'transactions' => [ + ['id' => 61311, 'invoice_collection_id' => 16912], + ], + ]; + + expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [ + $customer, + ['status' => 'active', 'target_type' => 'order_item_field', 'order_id' => 61311], + [61311 => true], + [16912 => true], + 'all', + ]))->toBeTrue(); + expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [ + $customer, + ['status' => 'active', 'target_type' => 'order', 'order_id' => 99999], + [61311 => true], + [16912 => true], + 'all', + ]))->toBeFalse(); + expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [ + $customer, + ['status' => 'active', 'target_type' => 'collected_order_invoice', 'target_id' => 16912], + [61311 => true], + [16912 => true], + 'all', + ]))->toBeTrue(); + expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [ + $customer, + ['status' => 'active', 'target_type' => 'xlvask_usage_log', 'customer_number' => 424242], + [61311 => true], + [16912 => true], + 'vehicle_subscriptions', + ]))->toBeFalse(); + expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [ + $customer, + ['status' => 'active', 'target_type' => 'xlvask_usage_log', 'customer_number' => 424242], + [61311 => true], + [16912 => true], + 'all', + ]))->toBeTrue(); +}); + +it('keeps order item preview context compact for the period response', function (): void { + global $db; + + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $GLOBALS['db'] ?? null; + $db = new class { + public string $lastQuery = ''; + + public function query(string $sql): object + { + $this->lastQuery = $sql; + return (object)['ok' => true]; + } + + public function fetch_all(object $result): array + { + return [[ + 'id' => '7001', + 'order_id' => '61311', + 'product_id' => '3', + 'reference' => 'REF', + 'notes' => str_repeat('x', 1024), + 'price' => '649', + 'quantity' => '1', + 'related_item_id' => '0', + 'product_name' => 'Forvogn', + 'product_base_price' => '649', + ]]; + } + }; + + try { + $rows = invoice_period_flag_service_invoke('getOrderItemsForPreview', [61311]); + + expect($rows)->toBe([[ + 'id' => 7001, + 'product_id' => 3, + 'product_name' => 'Forvogn', + 'quantity' => 1, + 'price' => 649, + ]]); + expect($db->lastQuery)->not->toContain('oi.reference'); + expect($db->lastQuery)->not->toContain('oi.notes'); + expect($db->lastQuery)->not->toContain('p.price AS product_base_price'); + } finally { + if ($hadDb) { + $db = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); + +it('formats stored manual flags with the creating superuser display name', function (): void { + global $db; + + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $GLOBALS['db'] ?? null; + $db = new class { + public function query(string $sql): object|false + { + if (str_contains($sql, 'SELECT display_name FROM users WHERE id = 42')) { + return new class { + public int $num_rows = 1; + + public function fetch_assoc(): array + { + return ['display_name' => 'Jeppe']; + } + }; + } + + return false; + } + }; + + try { + $flag = invoice_period_flag_service_invoke('formatStoredFlag', [[ + 'id' => 12, + 'source' => 'manual', + 'severity' => 'red', + 'status' => 'active', + 'target_type' => 'customer', + 'target_id' => 424242, + 'field' => null, + 'customer_number' => 424242, + 'order_id' => null, + 'order_item_id' => null, + 'invoice_collection_id' => null, + 'xlvask_usage_log_id' => null, + 'definition_key' => null, + 'fingerprint' => null, + 'reason' => 'Manual review', + 'status_reason' => null, + 'context_json' => null, + 'created_by' => 42, + 'status_changed_by' => null, + 'status_changed_at' => null, + 'created_at' => '2026-05-11 10:00:00', + 'updated_at' => '2026-05-11 10:00:00', + ]]); + + expect($flag['created_by'])->toBe(42); + expect($flag['created_by_name'])->toBe('Jeppe'); + } finally { + if ($hadDb) { + $db = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); + +it('validates supported manual flag fields by target type', function (): void { + expect(invoice_period_flag_service_invoke('normalizeField', ['order_field', 'reference']))->toBe('reference'); + expect(invoice_period_flag_service_invoke('normalizeField', ['order_item_field', 'price']))->toBe('price'); + expect(invoice_period_flag_service_invoke('normalizeField', ['customer', '']))->toBeNull(); + + invoice_period_flag_service_invoke('normalizeField', ['order_field', 'price']); +})->throws(InvalidArgumentException::class, 'Invalid order flag field.'); + +it('wires invoice period flag routes with explicit list create and update permissions', function (): void { + $content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain("\$this->get('/superuser/invoicing/period'"); + expect($content)->toContain("'list_invoice_period_flags' => 'List invoice period flags in the period response'"); + expect($content)->toContain("\$this->post('/superuser/invoicing/period/flags'"); + expect($content)->toContain("\$this->requirePermission('add_invoice_period_flag')"); + expect($content)->toContain("\$this->patch('/superuser/invoicing/period/flags/{id}/status'"); + expect($content)->toContain("\$this->post('/superuser/invoicing/period/flags/automatic/status'"); + expect($content)->toContain("\$this->requirePermission('update_invoice_period_flag_status')"); +}); + +it('uses the users display_name column in detector queries', function (): void { + $content = file_get_contents(app_path('classes/invoice_period_flag_service.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('u.display_name AS customer_name'); + expect($content)->toContain('COALESCE(u.display_name, x.Customer) AS customer_name'); + expect($content)->not->toContain('u.name'); +}); + +it('aggregates customer price overrides by customer number for price mismatch detection', function (): void { + $content = file_get_contents(app_path('classes/invoice_period_flag_service.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('MAX(po.percentage) AS percentage'); + expect($content)->toContain('GROUP BY discount_user.customer_number, po.product_or_category_id'); + expect($content)->toContain('product_discount.customer_number = o.customer_id'); + expect($content)->toContain('category_discount.customer_number = o.customer_id'); + expect($content)->not->toContain('po_product.user_id = u.id'); +}); + +it('guards optional customer vehicle deleted_at filtering behind a column check', function (): void { + $content = file_get_contents(app_path('classes/invoice_period_flag_service.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain("\$this->columnExists('customer_vehicles', 'deleted_at')"); + expect($content)->toContain('$deletedFilter'); + expect($content)->toContain('{$deletedFilter}'); +}); + +it('limits historical primary product lookup to current period registrations', function (): void { + $content = file_get_contents(app_path('classes/invoice_period_flag_service.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('getPrimaryProductHistory($dateFrom, array_column($primaryRows, \'reg_1\'))'); + expect($content)->toContain('private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers): array'); + expect($content)->toContain('AND o.reg_1 IN ({$registrationFilter})'); + expect($content)->not->toContain('$byReg = []'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingOrdersCalculationsHardeningTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingOrdersCalculationsHardeningTest.php index 82c74a93..a1b6ea36 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicingOrdersCalculationsHardeningTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingOrdersCalculationsHardeningTest.php @@ -27,3 +27,32 @@ it('uses centralized duplicate filtering for possible duplicate detection', func expect($content)->not->toBeFalse(); expect($content)->toContain('invoicing_period_utils::filterPossibleDuplicates($orders, 86400)'); }); + +it('provides a batched plain-row transaction query for invoicing period responses', function (): void { + $ordersFile = app_path('objects/orders_o.php'); + $content = file_get_contents($ordersFile); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('public function getPeriodTransactionsForCustomersInDateRange(?array $customers, string $dateFrom, string $dateTo): array') + ->and($content)->toContain('COALESCE(SUM(CASE WHEN oi.include_in_invoice = 1 THEN oi.price * oi.quantity ELSE 0 END), 0) AS net_amount') + ->and($content)->toContain('COALESCE(coi.booked_invoice_id, 0)') + ->and($content)->toContain('COALESCE(emo.invoice_id, 0)') + ->and($content)->toContain('department_flags.exclude_from_invoicing') + ->and($content)->not->toContain('$order->select((int)$row[\'id\']);' . PHP_EOL . ' $transactions[$customerNumber][]'); +}); + +it('adds guarded composite indexes for invoicing period lookups', function (): void { + $schemaFile = app_path('classes/orders_schema_bootstrap.php'); + $content = file_get_contents($schemaFile); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain("self::ensureIndex(\$db, 'orders', 'idx_orders_period_customer_created_deleted', 'customer_id, created_at, deleted_at')") + ->and($content)->toContain("self::ensureIndex(\$db, 'orders', 'idx_orders_period_created_deleted_customer', 'created_at, deleted_at, customer_id')") + ->and($content)->toContain("self::ensureIndex(\$db, 'order_items', 'idx_order_items_order_deleted', 'order_id, deleted_at')") + ->and($content)->toContain("self::ensureIndex(\$db, 'customer_attributes', 'idx_customer_attributes_attribute_user', 'attribute, user_id')") + ->and($content)->toContain('private static function indexExists(object $db, string $table, string $index): bool'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodDraftOverlayTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodDraftOverlayTest.php new file mode 100644 index 00000000..5eae3b40 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodDraftOverlayTest.php @@ -0,0 +1,296 @@ +getMethod($method); + $target->setAccessible(true); + return $target->invokeArgs(null, $args); +} + +function invoicing_period_draft_overlay_draft( + int $invoice_collection_id, + int $customer_number, + bool $is_period_relevant = true +): array { + return [ + 'invoice_collection_id' => $invoice_collection_id, + 'customer_number' => $customer_number, + 'created_at' => '2026-04-01 00:00:01', + 'closed_at' => '2026-04-01 00:00:01', + 'is_period_relevant' => $is_period_relevant, + ]; +} + +function invoicing_period_draft_overlay_transaction( + int $id, + ?int $invoice_collection_id, + bool $booked = false, + bool $excluded = false, + ?string $queue_status = null +): array { + return [ + 'id' => $id, + 'booked' => $booked, + 'excluded' => $excluded, + 'invoice_collection_id' => $invoice_collection_id, + 'queue_status' => $queue_status, + 'queue_job_id' => $queue_status === null ? null : 99, + ]; +} + +function invoicing_period_draft_overlay_reset_deleted_at_column_cache(): void +{ + $reflection = new ReflectionClass(InvoicingPeriodRoute::class); + $property = $reflection->getProperty('collectedOrderInvoicesHasDeletedAtColumn'); + $property->setAccessible(true); + $property->setValue(null, null); +} + +class InvoicingPeriodDraftOverlayFakeDbResult +{ + public int $num_rows; + + public function __construct(private array $rows = []) + { + $this->num_rows = count($rows); + } + + public function fetch_assoc(): ?array + { + return array_shift($this->rows); + } +} + +class InvoicingPeriodDraftOverlayFakeDb +{ + public string $selectSql = ''; + + public function __construct(private bool $hasDeletedAtColumn) + { + } + + public function escape_string(string $value): string + { + return addslashes($value); + } + + public function query(string $sql): InvoicingPeriodDraftOverlayFakeDbResult|bool + { + if (str_starts_with($sql, 'SHOW COLUMNS')) { + return new InvoicingPeriodDraftOverlayFakeDbResult( + $this->hasDeletedAtColumn ? [['Field' => 'deleted_at']] : [] + ); + } + + $this->selectSql = $sql; + return false; + } +} + +it('blocks invoicing when all actionable transactions are backed by valid e-conomic drafts', function (): void { + $draft = invoicing_period_draft_overlay_draft(14578, 42424242); + + $customer = [ + 'customer_number' => 42424242, + 'customer_name' => 'Draft Customer', + 'requires_action' => true, + 'transactions' => [ + invoicing_period_draft_overlay_transaction(501, 14578), + ], + ]; + + $result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [ + $customer, + [14578 => $draft], + [42424242 => [$draft]], + ]); + + expect($result['requires_action'])->toBeFalse(); + expect($result['queue'])->toBe([ + 'has_active_job' => false, + 'statuses' => [], + 'invoice_collection_ids' => [], + 'is_action_blocked' => false, + ]); + expect($result['draft'])->toBe([ + 'has_valid_draft' => true, + 'invoice_collection_ids' => [14578], + 'is_action_blocked' => true, + ]); +}); + +it('keeps invoicing available when valid drafts only cover part of the actionable work', function (): void { + $draft = invoicing_period_draft_overlay_draft(2001, 43434343); + + $customer = [ + 'customer_number' => 43434343, + 'customer_name' => 'Mixed Draft Customer', + 'requires_action' => true, + 'transactions' => [ + invoicing_period_draft_overlay_transaction(601, 2001), + invoicing_period_draft_overlay_transaction(602, null), + ], + ]; + + $result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [ + $customer, + [2001 => $draft], + [43434343 => [$draft]], + ]); + + expect($result['requires_action'])->toBeTrue(); + expect($result['draft'])->toBe([ + 'has_valid_draft' => true, + 'invoice_collection_ids' => [2001], + 'is_action_blocked' => false, + ]); +}); + +it('excludes errored, booked, deleted, and missing-external-id invoice collections at query time', function (): void { + $hadDb = array_key_exists('db', $GLOBALS); + $originalDb = $GLOBALS['db'] ?? null; + $fakeDb = new InvoicingPeriodDraftOverlayFakeDb(true); + invoicing_period_draft_overlay_reset_deleted_at_column_cache(); + $GLOBALS['db'] = $fakeDb; + + try { + invoicing_period_draft_overlay_invoke('getValidCollectedInvoiceDraftOverlay', [ + [ + 'all' => [ + [ + 'customer_number' => 45454545, + 'transactions' => [ + invoicing_period_draft_overlay_transaction(701, 3001), + ], + 'meta' => [ + 'fixed_pricing' => [ + 'price' => 1200, + ], + ], + ], + ], + ], + '2026-04-01', + '2026-04-30', + ]); + } finally { + if ($hadDb) { + $GLOBALS['db'] = $originalDb; + } else { + unset($GLOBALS['db']); + } + invoicing_period_draft_overlay_reset_deleted_at_column_cache(); + } + + expect($fakeDb->selectSql)->toContain('deleted_at IS NULL'); + expect($fakeDb->selectSql)->toContain('processor = 1'); + expect($fakeDb->selectSql)->toContain('external_id IS NOT NULL'); + expect($fakeDb->selectSql)->toContain("external_id <> ''"); + expect($fakeDb->selectSql)->toContain('booked_invoice_id IS NULL'); + expect($fakeDb->selectSql)->toContain('error_message IS NULL'); +}); + +it('still checks valid drafts when the collection table has no deleted marker column', function (): void { + $hadDb = array_key_exists('db', $GLOBALS); + $originalDb = $GLOBALS['db'] ?? null; + $fakeDb = new InvoicingPeriodDraftOverlayFakeDb(false); + invoicing_period_draft_overlay_reset_deleted_at_column_cache(); + $GLOBALS['db'] = $fakeDb; + + try { + invoicing_period_draft_overlay_invoke('getValidCollectedInvoiceDraftOverlay', [ + [ + 'all' => [ + [ + 'customer_number' => 12345679, + 'transactions' => [ + invoicing_period_draft_overlay_transaction(61415, 17389), + ], + ], + ], + ], + '2026-05-11', + '2026-05-11', + ]); + } finally { + if ($hadDb) { + $GLOBALS['db'] = $originalDb; + } else { + unset($GLOBALS['db']); + } + invoicing_period_draft_overlay_reset_deleted_at_column_cache(); + } + + expect($fakeDb->selectSql)->not->toContain('deleted_at IS NULL'); + expect($fakeDb->selectSql)->toContain('id IN (17389)'); + expect($fakeDb->selectSql)->toContain('processor = 1'); + expect($fakeDb->selectSql)->toContain('error_message IS NULL'); +}); + +it('blocks fixed-pricing and subscription customer-level work when a relevant valid draft exists', function (): void { + $draft = invoicing_period_draft_overlay_draft(3001, 45454545, true); + + $customer = [ + 'customer_number' => 45454545, + 'customer_name' => 'Subscription Draft Customer', + 'requires_action' => true, + 'transactions' => [], + 'meta' => [ + 'fixed_pricing' => [ + 'price' => 1200, + ], + ], + ]; + + $result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [ + $customer, + [], + [45454545 => [$draft]], + ]); + + expect($result['requires_action'])->toBeFalse(); + expect($result['draft'])->toBe([ + 'has_valid_draft' => true, + 'invoice_collection_ids' => [3001], + 'is_action_blocked' => true, + ]); +}); + +it('keeps queue blocking ahead of the draft label when all work is covered by queue or draft state', function (): void { + $draft = invoicing_period_draft_overlay_draft(5002, 46464646, true); + + $customer = [ + 'customer_number' => 46464646, + 'customer_name' => 'Queue And Draft Customer', + 'requires_action' => true, + 'transactions' => [ + invoicing_period_draft_overlay_transaction(801, 5001, false, false, 'QUEUED'), + invoicing_period_draft_overlay_transaction(802, 5002), + ], + 'queue' => [ + 'has_active_job' => true, + 'statuses' => ['QUEUED'], + 'invoice_collection_ids' => [5001], + 'is_action_blocked' => false, + ], + ]; + + $result = invoicing_period_draft_overlay_invoke('applyCollectedInvoiceDraftOverlayToCustomer', [ + $customer, + [5002 => $draft], + [46464646 => [$draft]], + ]); + + expect($result['requires_action'])->toBeFalse(); + expect($result['queue']['is_action_blocked'])->toBeTrue(); + expect($result['draft'])->toBe([ + 'has_valid_draft' => true, + 'invoice_collection_ids' => [5002], + 'is_action_blocked' => false, + ]); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php new file mode 100644 index 00000000..c9c4818e --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php @@ -0,0 +1,389 @@ +getMethod($method); + $target->setAccessible(true); + return $target->invokeArgs(null, $args); +} + +function invoicing_period_customer_card( + int $customerNumber, + string $customerName, + array $transactions, + bool $requiresAction = false, + array $extra = [] +): array { + return array_merge([ + 'id' => $customerNumber, + 'customer_number' => $customerNumber, + 'customer_name' => $customerName, + 'requires_action' => $requiresAction, + 'transactions' => $transactions, + 'meta' => [], + 'queue' => [ + 'has_active_job' => false, + 'statuses' => [], + 'invoice_collection_ids' => [], + 'is_action_blocked' => false, + ], + 'draft' => [ + 'has_valid_draft' => false, + 'invoice_collection_ids' => [], + 'is_action_blocked' => false, + ], + ], $extra); +} + +function invoicing_period_transaction(array $overrides = []): array +{ + return array_merge([ + 'id' => 9001, + 'date' => '2026-04-10 12:00:00', + 'amount' => 125.5, + 'booked' => false, + 'department_id' => 1, + 'customer_number' => 1001, + 'reference' => 'REF-9001', + 'po' => 'PO-9001', + 'notes' => 'Gate note', + 'reg_1' => 'AB12345', + 'reg_2' => '', + 'reg_3' => '', + 'excluded' => false, + 'invoice_collection_id' => 3001, + 'queue_status' => null, + 'queue_job_id' => null, + ], $overrides); +} + +it('detects paginated period mode only when pagination parameters are present', function (): void { + global $response; + + $previousResponse = $GLOBALS['response'] ?? null; + $previousGet = $_GET; + $previousMethod = $_SERVER['REQUEST_METHOD'] ?? null; + $response = new response(); + + try { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET = [ + 'dateFrom' => '2026-04-01', + 'dateTo' => '2026-04-30', + ]; + + expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toBeNull(); + + $_GET['page'] = '2'; + expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toMatchArray([ + 'periodView' => 'all', + 'page' => 2, + 'limit' => 100, + 'search' => '', + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]); + } finally { + $_GET = $previousGet; + if ($previousMethod === null) { + unset($_SERVER['REQUEST_METHOD']); + } else { + $_SERVER['REQUEST_METHOD'] = $previousMethod; + } + if ($previousResponse === null) { + unset($GLOBALS['response']); + } else { + $response = $previousResponse; + } + } +}); + +it('normalizes period pagination options and clamps invalid page and limit values', function (): void { + $options = invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[ + 'periodView' => 'not-a-view', + 'page' => '-4', + 'limit' => '500', + 'search' => ' Nordic ', + 'includeRequiresAction' => '0', + 'includeBooked' => 'false', + ]]); + + expect($options)->toBe([ + 'periodView' => 'all', + 'page' => 1, + 'limit' => 500, + 'search' => 'Nordic', + 'flagTab' => 'all', + 'includeRequiresAction' => false, + 'includeBooked' => false, + ]); + + expect(invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[ + 'limit' => '900', + ]]))->toMatchArray([ + 'limit' => 500, + ]); + + expect(invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[ + 'limit' => 'all', + ]]))->toMatchArray([ + 'limit' => 'all', + ]); + + expect(invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[ + 'periodView' => 'invoice_per_order', + 'page' => '3', + 'limit' => '0', + ]]))->toMatchArray([ + 'periodView' => 'invoice_per_order', + 'page' => 3, + 'limit' => 100, + ]); +}); + +it('slices only the active period view and keeps exact full-result type counts', function (): void { + $period = [ + 'dateFrom' => '2026-04-01 00:00:00', + 'dateTo' => '2026-04-30 23:59:59', + 'types' => [ + 'all' => [ + invoicing_period_customer_card(1001, 'Alpha Transport', [ + invoicing_period_transaction(['id' => 11, 'customer_number' => 1001, 'amount' => 50]), + ], false), + invoicing_period_customer_card(1002, 'Beta Transport', [ + invoicing_period_transaction([ + 'id' => 12, + 'customer_number' => 1002, + 'amount' => 75, + 'booked' => true, + 'excluded' => true, + 'reference' => 'REF-BETA', + 'po' => 'PO-BETA', + 'reg_1' => 'BB22222', + ]), + ], true), + invoicing_period_customer_card(1003, 'Gamma Transport', [ + invoicing_period_transaction([ + 'id' => 13, + 'customer_number' => 1003, + 'amount' => 125, + 'booked' => true, + ]), + ], false, [ + 'draft' => [ + 'has_valid_draft' => true, + 'invoice_collection_ids' => [3013], + 'is_action_blocked' => true, + ], + ]), + ], + 'fixed_pricing' => [ + invoicing_period_customer_card(1002, 'Beta Transport', [], true, [ + 'meta' => [ + 'fixed_pricing' => [ + 'price' => 999, + ], + ], + ]), + ], + 'invoice_per_order' => [ + invoicing_period_customer_card(1001, 'Alpha Transport', [], true, [ + 'flags' => [ + ['source' => 'manual', 'status' => 'active'], + ], + ]), + ], + ], + ]; + + $result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 2, + 'limit' => 1, + 'search' => '', + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]]); + + expect($result['pagination'])->toMatchArray([ + 'page' => 2, + 'per_page' => 1, + 'total' => 3, + 'search' => '', + ]); + + expect($result['period']['types']['all'])->toHaveCount(1); + expect($result['period']['types']['all'][0])->toMatchArray([ + 'customer_number' => 1002, + 'customer_name' => 'Beta Transport', + 'requires_action' => true, + 'meta' => [ + 'fixed_pricing' => [ + 'price' => 999, + ], + ], + ]); + expect($result['period']['types']['all'][0]['transactions'][0])->toMatchArray([ + 'id' => 12, + 'amount' => 75, + 'booked' => true, + 'excluded' => true, + 'reference' => 'REF-BETA', + 'po' => 'PO-BETA', + 'reg_1' => 'BB22222', + ]); + expect($result['period']['types']['fixed_pricing'])->toBe([]); + expect($result['period']['type_counts']['all'])->toBe([ + 'requires_action' => 1, + 'draft' => 1, + 'manual_flags' => 0, + 'automatic_flags' => 0, + 'completed' => 1, + 'total' => 3, + ]); + expect($result['period']['type_totals']['all'])->toBe([ + 'total' => 1174.0, + 'booked' => 125.0, + 'not_booked' => 1049.0, + ]); + expect($result['period']['type_totals']['fixed_pricing'])->toBe([ + 'total' => 999.0, + 'booked' => 0.0, + 'not_booked' => 999.0, + ]); + expect($result['period']['type_counts']['invoice_per_order']['manual_flags'])->toBe(1); +}); + +it('returns the entire active period view when the limit is all', function (): void { + $period = [ + 'dateFrom' => '2026-04-01 00:00:00', + 'dateTo' => '2026-04-30 23:59:59', + 'types' => [ + 'all' => [ + invoicing_period_customer_card(1101, 'Alpha', [ + invoicing_period_transaction(['id' => 31, 'customer_number' => 1101]), + ]), + invoicing_period_customer_card(1102, 'Beta', [ + invoicing_period_transaction(['id' => 32, 'customer_number' => 1102]), + ]), + invoicing_period_customer_card(1103, 'Gamma', [ + invoicing_period_transaction(['id' => 33, 'customer_number' => 1103]), + ]), + ], + ], + ]; + + $result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 3, + 'limit' => 'all', + 'search' => '', + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]]); + + expect($result['pagination'])->toMatchArray([ + 'page' => 1, + 'per_page' => 'all', + 'total' => 3, + ]); + expect(array_column($result['period']['types']['all'], 'customer_number'))->toBe([1101, 1102, 1103]); +}); + +it('searches customer fields and order fields at the customer-card level', function (): void { + $period = [ + 'dateFrom' => '2026-04-01 00:00:00', + 'dateTo' => '2026-04-30 23:59:59', + 'types' => [ + 'all' => [ + invoicing_period_customer_card(2001, 'Solaris Fleet', [ + invoicing_period_transaction([ + 'id' => 21, + 'customer_number' => 2001, + 'reference' => 'REF-KEEP', + 'po' => 'PO-KEEP', + ]), + ]), + invoicing_period_customer_card(2002, 'Nordic Logistics', [ + invoicing_period_transaction([ + 'id' => 22, + 'customer_number' => 2002, + 'reference' => 'MISS', + 'po' => 'PO-777', + 'notes' => 'Driver waits at gate', + 'reg_1' => 'CD33333', + ]), + invoicing_period_transaction([ + 'id' => 23, + 'customer_number' => 2002, + 'reference' => 'SECOND-LINE', + ]), + ]), + ], + ], + ]; + + $result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 1, + 'limit' => 25, + 'search' => 'po-777', + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]]); + + expect($result['pagination']['total'])->toBe(1); + expect($result['period']['types']['all'])->toHaveCount(1); + expect($result['period']['types']['all'][0]['customer_number'])->toBe(2002); + expect($result['period']['types']['all'][0]['transactions'])->toHaveCount(2); + + $registrationMatch = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 1, + 'limit' => 25, + 'search' => 'cd33333', + 'includeRequiresAction' => true, + 'includeBooked' => true, + ]]); + expect($registrationMatch['period']['types']['all'][0]['customer_name'])->toBe('Nordic Logistics'); +}); + +it('applies requires-action and booked visibility filters before counting and slicing', function (): void { + $period = [ + 'dateFrom' => '2026-04-01 00:00:00', + 'dateTo' => '2026-04-30 23:59:59', + 'types' => [ + 'all' => [ + invoicing_period_customer_card(3001, 'Needs Action', [ + invoicing_period_transaction(['customer_number' => 3001, 'booked' => false]), + ], true), + invoicing_period_customer_card(3002, 'Already Booked', [ + invoicing_period_transaction(['customer_number' => 3002, 'booked' => true]), + ], false), + invoicing_period_customer_card(3003, 'Still Open', [ + invoicing_period_transaction(['customer_number' => 3003, 'booked' => false]), + ], false), + ], + ], + ]; + + $result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [ + 'periodView' => 'all', + 'page' => 1, + 'limit' => 25, + 'search' => '', + 'includeRequiresAction' => false, + 'includeBooked' => false, + ]]); + + expect($result['pagination']['total'])->toBe(1); + expect($result['period']['type_counts']['all']['total'])->toBe(1); + expect($result['period']['types']['all'][0]['customer_number'])->toBe(3003); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodQueueOverlayTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodQueueOverlayTest.php index e009d724..2db9e7bf 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodQueueOverlayTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodQueueOverlayTest.php @@ -139,3 +139,22 @@ it('blocks fixed-pricing or subscription customers with no transactions when a r 'is_action_blocked' => true, ]); }); + +it('normalizes targeted customer number filters from comma-separated or repeated values', function (): void { + expect(invoicing_period_queue_overlay_invoke('normalizeCustomerNumbers', [ + '42424242, 43434343,42424242,0,not-a-number', + ]))->toBe([42424242, 43434343]); + + expect(invoicing_period_queue_overlay_invoke('normalizeCustomerNumbers', [ + ['45454545', 45454545, '46464646'], + ]))->toBe([45454545, 46464646]); +}); + +it('filters period customer number candidates to targeted customers only', function (): void { + $result = invoicing_period_queue_overlay_invoke('filterCustomerNumbers', [ + [42424242, '43434343', 45454545], + [43434343, 99999999], + ]); + + expect($result)->toBe([43434343]); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php index afe913e5..62becc2a 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodRouteGuardsTest.php @@ -1,5 +1,9 @@ toBeGreaterThanOrEqual(5); }); +it('keeps the main period response local-only for booked state and customer names', function (): void { + $routeFile = app_path('routes/InvoicingPeriodRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->not->toContain('isBooked(true)') + ->and($content)->toContain('isTransactionBookedFromLocalState($transaction)') + ->and($content)->toContain('SELECT booked_invoice_id FROM collected_order_invoices') + ->and($content)->toContain('SELECT invoice_id FROM economic_module_orders') + ->and($content)->toContain('getCustomerNames(array_keys($customer_numbers), false)') + ->and($content)->toContain('getCustomerNames(array_map(\'intval\', $customer_numbers), false)'); +}); + +it('streams the main period response instead of encoding the full payload at once', function (): void { + $routeFile = app_path('routes/InvoicingPeriodRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('private static function streamInvoicingPeriodResponse(array $period): void') + ->and($content)->toContain('$period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers);') + ->and($content)->toContain('self::streamInvoicingPeriodResponse($period);') + ->and($content)->not->toContain('$response->success([' . PHP_EOL . ' ...self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers)') + ->and($content)->toContain('echo self::jsonFragment($customer);'); +}); + +it('maps batched period transaction rows to the legacy transaction response shape', function (): void { + $reflection = new ReflectionClass(InvoicingPeriodRoute::class); + $method = $reflection->getMethod('constructTransactionObjectFromPeriodRow'); + $method->setAccessible(true); + + $transaction = $method->invokeArgs(null, [[ + 'id' => '42', + 'created_at' => '2026-04-10 12:34:56', + 'net_amount' => '123.50', + 'booked' => '1', + 'department_id' => '7', + 'customer_id' => '27983', + 'order_reference' => 'REF-42', + 'order_po' => 'PO-42', + 'order_notes' => 'Driver note', + 'reg_1' => 'AB12345', + 'reg_2' => 'CD67890', + 'reg_3' => '', + 'invoice_collection_id' => '314', + 'include_in_invoice_effective' => '0', + ]]); + + expect($transaction)->toMatchArray([ + 'id' => 42, + 'date' => '2026-04-10 12:34:56', + 'amount' => 123.5, + 'booked' => true, + 'department_id' => 7, + 'customer_number' => 27983, + 'reference' => 'REF-42', + 'po' => 'PO-42', + 'notes' => 'Driver note', + 'reg_1' => 'AB12345', + 'reg_2' => 'CD67890', + 'reg_3' => '', + 'excluded' => true, + 'invoice_collection_id' => 314, + 'queue_status' => null, + 'queue_job_id' => null, + ]); +}); + +it('uses batched period transactions and keyed customer maps in the main period route', function (): void { + $content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('getPeriodTransactionsForCustomersInDateRange(') + ->and($content)->toContain('private static function indexCustomersByNumber(array $customers): array') + ->and($content)->toContain('$customers_by_number = self::indexCustomersByNumber($customersWithTransactions);') + ->and($content)->toContain('invoicing_period_utils::filterPossibleDuplicates($ordersByRegistration, 86400)') + ->and($content)->not->toContain('getOrdersWithPossibleDuplicates($dateFrom, $dateTo)'); +}); + +it('falls back to configured e-conomic default department for missing customer default department in distributions', function (): void { + $content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content) + ->toContain('private static function getEconomicFallbackDepartmentId(): int') + ->and($content)->toContain('(new economic())->getDefaultDistributionDepartmentId()') + ->and($content)->toContain(': self::getEconomicFallbackDepartmentId();') + ->and($content)->toContain("\$department_totals[\$fallback_department_id] = (float)\$customer['meta']['fixed_pricing']['price'];"); +}); diff --git a/services/nginx/app/tests/Unit/MotorApi/MotorApiCachedResultTest.php b/services/nginx/app/tests/Unit/MotorApi/MotorApiCachedResultTest.php new file mode 100644 index 00000000..4742168d --- /dev/null +++ b/services/nginx/app/tests/Unit/MotorApi/MotorApiCachedResultTest.php @@ -0,0 +1,22 @@ +meta[$key] = $value; + } + }; + + motorapi::addCachedMetaIfPossible($response); + + expect($response->meta)->toBe(['cached' => true]); +}); diff --git a/services/nginx/app/tests/Unit/Orders/OrderBookingsCompletionDedupTest.php b/services/nginx/app/tests/Unit/Orders/OrderBookingsCompletionDedupTest.php index ececa258..d62df2b2 100644 --- a/services/nginx/app/tests/Unit/Orders/OrderBookingsCompletionDedupTest.php +++ b/services/nginx/app/tests/Unit/Orders/OrderBookingsCompletionDedupTest.php @@ -15,13 +15,30 @@ if (!class_exists('OrderBookingsCompletionOrderDouble')) { { $this->id = -1; $this->booking_id = new object_property('orders', -1, 'booking_id', 'int', false); + $this->customer_id = new object_property('orders', -1, 'customer_id', 'int', true); + $this->department_id = new object_property('orders', -1, 'department_id', 'int', true); $this->safety_seal = new object_property('orders', -1, 'safety_seal', 'string', false); $this->completed_at = new object_property('orders', -1, 'completed_at', 'timestamp', false); + $this->customer_id->set(111111); + $this->department_id->set(10); } + public bool $washCertificateAttached = false; + public bool $containsWashCertificate = false; + public function objectChanged(): void { } + + public function containsWashCertificateItem(): bool + { + return $this->containsWashCertificate; + } + + public function hasWashCertificateAttached(): bool + { + return $this->washCertificateAttached; + } } } @@ -39,8 +56,12 @@ if (!class_exists('OrderBookingsCompletionDouble')) { { $this->id = -1; $this->linkedOrder = $linkedOrder; + $this->customer_number = new object_property('order_bookings', -1, 'customer_number', 'int', true); + $this->department = new object_property('order_bookings', -1, 'department', 'int', true); $this->order_id = new object_property('order_bookings', -1, 'order_id', 'int', false); $this->items = new object_property('order_bookings', -1, 'items', 'json', false); + $this->customer_number->set(111111); + $this->department->set(10); $this->items->set([]); } @@ -65,9 +86,10 @@ if (!class_exists('OrderBookingsCompletionDouble')) { return $this->linkedOrder; } - protected function attachWashCertificate(int $user_id, string $safety_seal = null): void + protected function attachWashCertificate(int $user_id, ?string $safety_seal = null): void { $this->attachCalls++; + $this->linkedOrder->washCertificateAttached = true; } public function sendWashCertificateToCustomer(): void @@ -77,7 +99,7 @@ if (!class_exists('OrderBookingsCompletionDouble')) { } } -it('does not create or email a duplicate wash certificate when a booking is already linked to a pos order', function (): void { +it('attaches and emails a wash certificate when a booking is already linked to a matching pos order without one', function (): void { $order = new OrderBookingsCompletionOrderDouble(); $booking = new OrderBookingsCompletionDouble($order); $booking->order_id->set(321); @@ -85,6 +107,50 @@ it('does not create or email a duplicate wash certificate when a booking is alre $booking->completeBooking(77, 'LINKED-SEAL'); + expect($order->getSafetySealValue())->toBe('LINKED-SEAL'); + expect($booking->attachCalls)->toBe(1); + expect($booking->sendCalls)->toBe(1); +}); + +it('rejects wash certificate completion when the linked pos order belongs to another booking context', function (): void { + $order = new OrderBookingsCompletionOrderDouble(); + $order->customer_id->set(222222); + $order->department_id->set(99); + + $booking = new OrderBookingsCompletionDouble($order); + $booking->order_id->set(321); + $booking->containsWashCertificate = true; + + expect(fn() => $booking->completeBooking(77, 'LINKED-SEAL')) + ->toThrow(Exception::class, 'Linked order does not match booking customer or department'); + expect($order->getSafetySealValue())->toBeNull(); + expect($booking->attachCalls)->toBe(0); + expect($booking->sendCalls)->toBe(0); +}); + +it('uses the linked pos order wash certificate item added during mobile completion', function (): void { + $order = new OrderBookingsCompletionOrderDouble(); + $order->containsWashCertificate = true; + $booking = new OrderBookingsCompletionDouble($order); + $booking->order_id->set(321); + $booking->containsWashCertificate = false; + + $booking->completeBooking(77, 'MOBILE-SEAL'); + + expect($order->getSafetySealValue())->toBe('MOBILE-SEAL'); + expect($booking->attachCalls)->toBe(1); + expect($booking->sendCalls)->toBe(1); +}); + +it('does not create or email a duplicate wash certificate when a linked pos order already has one', function (): void { + $order = new OrderBookingsCompletionOrderDouble(); + $order->washCertificateAttached = true; + $booking = new OrderBookingsCompletionDouble($order); + $booking->order_id->set(321); + $booking->containsWashCertificate = true; + + $booking->completeBooking(77, 'LINKED-SEAL'); + expect($order->getSafetySealValue())->toBe('LINKED-SEAL'); expect($booking->attachCalls)->toBe(0); expect($booking->sendCalls)->toBe(0); diff --git a/services/nginx/app/tests/Unit/Redis/RedisAtomicReservationTest.php b/services/nginx/app/tests/Unit/Redis/RedisAtomicReservationTest.php index 500ccf79..dc493977 100644 --- a/services/nginx/app/tests/Unit/Redis/RedisAtomicReservationTest.php +++ b/services/nginx/app/tests/Unit/Redis/RedisAtomicReservationTest.php @@ -13,6 +13,12 @@ class RedisAtomicReservationTestClient extends PredisClient { } + public function mget(array $keys): array + { + $this->calls[] = ['mget', $keys]; + return $this->returnValue; + } + public function set(...$arguments): mixed { $this->calls[] = $arguments; @@ -71,3 +77,26 @@ it('returns false when the slot is already claimed and clamps ttl to one second' ['goal_alert_sent:22:2026-03-17', '1', 'EX', 1, 'NX'], ]); }); + +it('does not send empty mget commands to redis', function (): void { + global $REDIS_CONFIG; + + $REDIS_CONFIG = [ + 'host' => 'redis', + 'database' => 0, + 'password' => '', + ]; + + $client = new RedisAtomicReservationTestClient(['cached-value']); + + $redis = new redis(); + redis_test_inject_client($redis, $client); + + expect($redis->mget([]))->toBe([]); + expect($client->calls)->toBe([]); + + expect($redis->mget(['cache-key']))->toBe(['cached-value']); + expect($client->calls)->toBe([ + ['mget', ['cache-key']], + ]); +}); diff --git a/services/nginx/app/tests/Unit/Release/ReleaseManagerStatusOverviewTest.php b/services/nginx/app/tests/Unit/Release/ReleaseManagerStatusOverviewTest.php new file mode 100644 index 00000000..f0e13523 --- /dev/null +++ b/services/nginx/app/tests/Unit/Release/ReleaseManagerStatusOverviewTest.php @@ -0,0 +1,680 @@ +setAccessible(true); + + return $method->invoke($manager, array_replace([ + 'generated_at' => '2026-05-20T10:00:00+00:00', + 'channels' => [], + 'deployment_targets' => [], + 'service_sets' => [], + 'deployments' => [], + ], $summary)); +} + +function releaseStatusChannelBySlug(array $overview, string $slug): array +{ + foreach ($overview['channels'] as $channel) { + if (($channel['channel_slug'] ?? '') === $slug) { + return $channel; + } + } + + throw new RuntimeException('Release status channel not found: ' . $slug); +} + +function releaseStatusServiceByKey(array $channel, string $key): array +{ + foreach ($channel['services'] as $service) { + if (($service['service_key'] ?? '') === $key) { + return $service; + } + } + + throw new RuntimeException('Release status service not found: ' . $key); +} + +function releaseStatusReadyService(string $kind, int $id): array +{ + return [ + 'id' => $id, + 'resource_uuid' => $kind . '-resource', + 'resource_name' => ucfirst($kind), + 'deployment_status' => 'deployed', + 'availability_state' => 'failover_ready', + 'replication' => [ + 'status' => 'ok', + 'last_status' => [ + 'status' => 'ok', + 'blockers' => [], + ], + ], + ]; +} + +function releaseStatusReadyVersions(int $bundleId = 7): array +{ + return [ + 'bundle_id' => $bundleId, + 'bundle_label' => '#' . $bundleId, + 'frontend' => [ + 'id' => 101, + 'version_label' => 'frontend-2026-05-20', + 'status' => 'active', + ], + 'api' => [ + 'id' => 102, + 'version_label' => 'api-2026-05-20', + 'status' => 'active', + ], + ]; +} + +it('marks a channel ready when all release services are healthy', function (): void { + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 1, + 'slug' => 'stable', + 'name' => 'Stable', + 'default_channel' => true, + 'enabled' => true, + 'frontend_base_url' => 'https://app.example.test', + 'api_base_url' => 'https://api.example.test', + 'versions' => releaseStatusReadyVersions(), + ], + ], + 'deployment_targets' => [ + ['id' => 11, 'channel_id' => 1, 'app' => 'frontend', 'repository' => 'truckwash/front-end-vue'], + ['id' => 12, 'channel_id' => 1, 'app' => 'api', 'repository' => 'truckwash/backend-php'], + ], + 'service_sets' => [ + [ + 'id' => 21, + 'channel_id' => 1, + 'mode' => 'isolated_stack', + 'stack' => [ + 'database' => releaseStatusReadyService('database', 31), + 'redis' => releaseStatusReadyService('redis', 32), + 'minio' => releaseStatusReadyService('minio', 33), + ], + ], + ], + 'deployments' => [ + ['id' => 41, 'channel_id' => 1, 'app' => 'frontend', 'status' => 'deployed'], + ['id' => 42, 'channel_id' => 1, 'app' => 'api', 'status' => 'deployed'], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'stable'); + + expect($overview['state'])->toBe('ready') + ->and($overview['totals']['critical'])->toBe(0) + ->and($overview['totals']['warning'])->toBe(0) + ->and($overview['totals']['services'])->toBe(5) + ->and($channel['readiness'])->toBe('ready') + ->and($channel['services'])->toHaveCount(5); +}); + +it('does not block channels on unhealthy data targets when data services are production shared', function (): void { + $database = releaseStatusReadyService('database', 71); + $database['deployment_status'] = 'reconcile_failed'; + $database['availability_state'] = 'degraded'; + + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 2, + 'slug' => 'beta', + 'name' => 'Beta', + 'default_channel' => false, + 'enabled' => true, + 'frontend_base_url' => 'https://beta.example.test', + 'api_base_url' => 'https://api-beta.example.test', + 'versions' => releaseStatusReadyVersions(8), + ], + ], + 'deployment_targets' => [ + ['id' => 51, 'channel_id' => 2, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-beta'], + ['id' => 52, 'channel_id' => 2, 'app' => 'api', 'coolify_service_uuid' => 'api-beta'], + ], + 'service_sets' => [ + [ + 'id' => 53, + 'channel_id' => 2, + 'mode' => 'attach_existing', + 'stack' => [ + 'database' => $database, + 'redis' => releaseStatusReadyService('redis', 75), + 'minio' => releaseStatusReadyService('minio', 76), + ], + ], + ], + 'deployments' => [ + ['id' => 61, 'channel_id' => 2, 'app' => 'frontend', 'status' => 'deployed'], + ['id' => 62, 'channel_id' => 2, 'app' => 'api', 'status' => 'deployed'], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'beta'); + + expect($channel['readiness'])->toBe('ready') + ->and($channel['issues'])->toBe([]) + ->and(releaseStatusServiceByKey($channel, 'database'))->toMatchArray([ + 'status' => 'production_shared', + 'state' => 'ready', + 'severity' => 'ok', + 'issue_type' => null, + ]); +}); + +it('marks beta ready from the production frontend and API services', function (): void { + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 1, + 'slug' => 'stable', + 'name' => 'Stable', + 'default_channel' => true, + 'enabled' => true, + 'frontend_base_url' => 'https://app.example.test', + 'api_base_url' => 'https://api.example.test', + 'versions' => releaseStatusReadyVersions(), + ], + [ + 'id' => 2, + 'slug' => 'beta', + 'name' => 'Beta', + 'default_channel' => false, + 'enabled' => true, + 'versions' => [], + 'availability' => [ + 'configured' => false, + 'status' => 'unconfigured', + 'missing' => ['frontend_version', 'frontend_base_url', 'api_version', 'api_base_url'], + ], + ], + ], + 'deployment_targets' => [ + ['id' => 11, 'channel_id' => 1, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-prod'], + ['id' => 12, 'channel_id' => 1, 'app' => 'api', 'coolify_service_uuid' => 'api-prod'], + ], + 'deployments' => [ + ['id' => 41, 'channel_id' => 1, 'app' => 'frontend', 'status' => 'deployed'], + ['id' => 42, 'channel_id' => 1, 'app' => 'api', 'status' => 'deployed'], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'beta'); + + expect($channel['readiness'])->toBe('ready') + ->and($channel['service_policy'])->toBe('production_shared') + ->and($channel['service_channel_slug'])->toBe('stable') + ->and($channel['missing_values'])->toBe([]) + ->and($channel['availability'])->toMatchArray([ + 'configured' => true, + 'missing' => [], + 'frontend_base_url' => 'https://app.example.test', + 'api_base_url' => 'https://api.example.test', + ]) + ->and(releaseStatusServiceByKey($channel, 'frontend'))->toMatchArray([ + 'status' => 'production_shared', + 'service_policy' => 'production_shared', + 'service_channel_slug' => 'stable', + ]) + ->and(releaseStatusServiceByKey($channel, 'api'))->toMatchArray([ + 'status' => 'production_shared', + 'service_policy' => 'production_shared', + 'service_channel_slug' => 'stable', + ]); +}); + +it('ignores missing legacy bundles but still blocks on missing versions and URLs', function (): void { + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 2, + 'slug' => 'beta', + 'name' => 'Beta', + 'default_channel' => false, + 'enabled' => true, + 'versions' => [], + 'availability' => [ + 'configured' => false, + 'status' => 'unconfigured', + 'missing' => [ + 'release_bundle', + 'frontend_version', + 'frontend_base_url', + 'api_version', + 'api_base_url', + ], + ], + ], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'beta'); + $missingLabels = array_column($channel['missing_values'], 'label'); + $missingKeys = array_column($channel['missing_values'], 'key'); + + expect($overview['state'])->toBe('blocked') + ->and($overview['totals']['critical'])->toBeGreaterThanOrEqual(4) + ->and($channel['readiness'])->toBe('blocked') + ->and($missingKeys)->not->toContain('release_bundle') + ->and($missingLabels)->not->toContain('release bundle') + ->and($missingLabels)->toContain('frontend version') + ->and($missingLabels)->toContain('frontend URL') + ->and($missingLabels)->toContain('API version') + ->and($missingLabels)->toContain('API URL') + ->and($overview['issues'][0])->toMatchArray([ + 'severity' => 'critical', + 'type' => 'missing_value', + 'channel_slug' => 'beta', + ]); +}); + +it('marks a branch-based channel ready without a release bundle when app versions and URLs exist', function (): void { + $versions = releaseStatusReadyVersions(0); + unset($versions['bundle_id'], $versions['bundle_label']); + + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 2, + 'slug' => 'beta', + 'name' => 'Beta', + 'default_channel' => false, + 'enabled' => true, + 'frontend_base_url' => 'https://api-v2.truckwash.io/beta/frontend', + 'api_base_url' => 'https://api-v2.truckwash.io/beta/api', + 'versions' => $versions, + ], + ], + 'deployment_targets' => [ + ['id' => 21, 'channel_id' => 2, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-beta'], + ['id' => 22, 'channel_id' => 2, 'app' => 'api', 'coolify_service_uuid' => 'api-beta'], + ], + 'deployments' => [ + ['id' => 61, 'channel_id' => 2, 'app' => 'frontend', 'status' => 'deployed'], + ['id' => 62, 'channel_id' => 2, 'app' => 'api', 'status' => 'deployed'], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'beta'); + + expect($overview['state'])->toBe('ready') + ->and($channel['readiness'])->toBe('ready') + ->and($channel['availability'])->toMatchArray([ + 'configured' => true, + 'missing' => [], + 'status' => 'ready', + ]) + ->and($channel['issues'])->toBe([]) + ->and($channel['missing_values'])->toBe([]); +}); + +it('does not report frontend or API URLs missing when target auto endpoints are resolvable', function (): void { + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 2, + 'slug' => 'beta', + 'name' => 'Beta', + 'default_channel' => false, + 'enabled' => true, + 'versions' => releaseStatusReadyVersions(), + ], + ], + 'deployment_targets' => [ + [ + 'id' => 21, + 'channel_id' => 2, + 'channel_slug' => 'beta', + 'app' => 'frontend', + 'repository' => 'truckwash/front-end-vue', + 'branch' => 'master', + 'coolify_service_uuid' => 'frontend-beta-service', + 'deploy_context' => [ + 'endpoint_mode' => 'auto', + 'public_gateway_host' => 'gateway.example.test', + ], + ], + [ + 'id' => 22, + 'channel_id' => 2, + 'channel_slug' => 'beta', + 'app' => 'api', + 'repository' => 'truckwash/backend-php', + 'branch' => 'master', + 'coolify_service_uuid' => 'api-beta-service', + 'deploy_context' => [ + 'endpoint_mode' => 'auto', + 'public_gateway_host' => 'gateway.example.test', + ], + ], + ], + 'service_sets' => [ + [ + 'id' => 31, + 'channel_id' => 2, + 'mode' => 'isolated_stack', + 'stack' => [ + 'database' => releaseStatusReadyService('database', 41), + 'redis' => releaseStatusReadyService('redis', 42), + 'minio' => releaseStatusReadyService('minio', 43), + ], + ], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'beta'); + $missingKeys = array_column($channel['missing_values'], 'key'); + + expect($missingKeys)->not->toContain('frontend_base_url') + ->and($missingKeys)->not->toContain('api_base_url') + ->and($channel['availability'])->toMatchArray([ + 'configured' => true, + 'frontend_base_url' => 'https://gateway.example.test/beta/frontend', + 'api_base_url' => 'https://gateway.example.test/beta/api', + ]) + ->and($channel['readiness'])->toBe('ready'); +}); + +it('does not surface legacy release bundle actions as readiness blockers', function (): void { + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 20, + 'slug' => 'beta', + 'name' => 'Beta', + 'default_channel' => false, + 'enabled' => true, + 'frontend_base_url' => 'https://beta.example.test', + 'api_base_url' => 'https://api-beta.example.test', + 'versions' => [], + 'availability' => [ + 'configured' => false, + 'status' => 'unconfigured', + 'missing' => ['release_bundle'], + ], + ], + ], + 'bundles' => [ + ['id' => 91, 'channel_id' => 20, 'status' => 'deployed', 'version_label' => 'beta-bundle'], + ['id' => 92, 'channel_id' => 20, 'status' => 'failed', 'version_label' => 'failed-bundle'], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'beta'); + + expect($channel['readiness'])->toBe('ready') + ->and($channel['availability'])->toMatchArray([ + 'configured' => true, + 'missing' => [], + 'status' => 'ready', + ]) + ->and($channel['issues'])->toBe([]) + ->and($channel['missing_values'])->toBe([]); +}); + +it('surfaces failed latest deployments as critical promotion blockers', function (): void { + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 3, + 'slug' => 'canary', + 'name' => 'Canary', + 'default_channel' => false, + 'enabled' => true, + 'frontend_base_url' => 'https://canary.example.test', + 'api_base_url' => 'https://api-canary.example.test', + 'versions' => releaseStatusReadyVersions(9), + 'availability' => [ + 'configured' => true, + 'status' => 'ready', + 'missing' => [], + ], + ], + ], + 'deployment_targets' => [ + ['id' => 51, 'channel_id' => 3, 'app' => 'frontend', 'repository' => 'truckwash/front-end-vue', 'coolify_service_uuid' => 'frontend-canary'], + ['id' => 52, 'channel_id' => 3, 'app' => 'api', 'repository' => 'truckwash/backend-php', 'coolify_service_uuid' => 'api-canary'], + ], + 'service_sets' => [ + [ + 'id' => 53, + 'channel_id' => 3, + 'mode' => 'isolated_stack', + 'stack' => [ + 'database' => releaseStatusReadyService('database', 54), + 'redis' => releaseStatusReadyService('redis', 55), + 'minio' => releaseStatusReadyService('minio', 56), + ], + ], + ], + 'deployments' => [ + [ + 'id' => 57, + 'channel_id' => 3, + 'app' => 'api', + 'status' => 'failed', + 'failure_summary' => [ + 'root_cause' => 'Composer install failed.', + 'next_action' => 'Open the deployment logs and fix composer dependencies.', + ], + ], + ['id' => 58, 'channel_id' => 3, 'app' => 'frontend', 'status' => 'deployed'], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'canary'); + $api = releaseStatusServiceByKey($channel, 'api'); + + expect($overview['state'])->toBe('blocked') + ->and($channel['readiness'])->toBe('blocked') + ->and($api)->toMatchArray([ + 'severity' => 'critical', + 'state' => 'failed', + 'issue_type' => 'failed_deployment', + 'deployment_id' => 57, + ]) + ->and($channel['issues'][0])->toMatchArray([ + 'severity' => 'critical', + 'type' => 'failed_deployment', + 'deployment_id' => 57, + 'next_action' => 'Open the deployment logs and fix composer dependencies.', + ]) + ->and($channel['issues'][0]['key'])->toBe('failed_deployment:3:api::57:') + ->and($channel['issues'][0]['impact'])->toContain('cannot be promoted') + ->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('retry_deployment'); +}); + +it('offers application target preparation for path routed Coolify failures', function (): void { + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 30, + 'slug' => 'internal', + 'name' => 'Internal', + 'default_channel' => false, + 'enabled' => true, + 'frontend_base_url' => 'https://internal.example.test', + 'api_base_url' => 'https://api-internal.example.test', + 'versions' => releaseStatusReadyVersions(19), + 'availability' => [ + 'configured' => true, + 'status' => 'ready', + 'missing' => [], + ], + ], + ], + 'deployment_targets' => [ + ['id' => 81, 'channel_id' => 30, 'app' => 'frontend', 'repository' => 'truckwash/front-end-vue', 'coolify_service_uuid' => 'frontend-internal'], + ['id' => 82, 'channel_id' => 30, 'app' => 'api', 'repository' => 'truckwash/backend-php', 'coolify_service_uuid' => 'legacy-api-service'], + ], + 'service_sets' => [ + [ + 'id' => 83, + 'channel_id' => 30, + 'mode' => 'isolated_stack', + 'stack' => [ + 'database' => releaseStatusReadyService('database', 84), + 'redis' => releaseStatusReadyService('redis', 85), + 'minio' => releaseStatusReadyService('minio', 86), + ], + ], + ], + 'deployments' => [ + [ + 'id' => 87, + 'channel_id' => 30, + 'app' => 'api', + 'status' => 'failed', + 'failure_summary' => [ + 'root_cause' => 'Path-routed release targets require a Coolify application resource with StripPrefix labels.', + 'next_action' => 'Set coolify_resource_type=application or migrate this target before deploying.', + ], + ], + ['id' => 88, 'channel_id' => 30, 'app' => 'frontend', 'status' => 'deployed'], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'internal'); + $actions = $channel['issues'][0]['actions']; + $actionIds = array_column($actions, 'id'); + $prepareAction = $actions[array_search('prepare_application_target', $actionIds, true)]; + + expect($actionIds)->toContain('retry_deployment') + ->and($actionIds)->toContain('prepare_application_target') + ->and($prepareAction)->toMatchArray([ + 'requires_confirmation' => true, + 'requires_input' => false, + 'disabled_reason' => '', + ]); +}); + +it('flags isolated stacks that are missing database Redis and MinIO services', function (): void { + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 4, + 'slug' => 'internal', + 'name' => 'Internal', + 'default_channel' => false, + 'enabled' => true, + 'frontend_base_url' => 'https://internal.example.test', + 'api_base_url' => 'https://api-internal.example.test', + 'versions' => releaseStatusReadyVersions(11), + 'availability' => [ + 'configured' => true, + 'status' => 'ready', + 'missing' => [], + ], + ], + ], + 'deployment_targets' => [ + ['id' => 61, 'channel_id' => 4, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-internal'], + ['id' => 62, 'channel_id' => 4, 'app' => 'api', 'coolify_service_uuid' => 'api-internal'], + ], + 'service_sets' => [ + [ + 'id' => 63, + 'channel_id' => 4, + 'mode' => 'isolated_stack', + 'stack' => [ + 'frontend' => ['id' => 61], + 'api' => ['id' => 62], + ], + 'data_services' => [], + ], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'internal'); + + expect($channel['readiness'])->toBe('blocked') + ->and(releaseStatusServiceByKey($channel, 'database'))->toMatchArray([ + 'severity' => 'critical', + 'issue_type' => 'missing_value', + 'missing_key' => 'database_service', + ]) + ->and(releaseStatusServiceByKey($channel, 'redis'))->toMatchArray([ + 'severity' => 'critical', + 'issue_type' => 'missing_value', + 'missing_key' => 'redis_service', + ]) + ->and(releaseStatusServiceByKey($channel, 'minio'))->toMatchArray([ + 'severity' => 'critical', + 'issue_type' => 'missing_value', + 'missing_key' => 'minio_service', + ]) + ->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('complete_data_services'); +}); + +it('maps degraded Coolify targets and reconcile failures to release service issues', function (): void { + $database = releaseStatusReadyService('database', 71); + $database['deployment_status'] = 'reconcile_failed'; + $database['availability_state'] = 'degraded'; + + $overview = releaseStatusOverviewForTest([ + 'channels' => [ + [ + 'id' => 5, + 'slug' => 'preview', + 'name' => 'Preview', + 'default_channel' => false, + 'enabled' => true, + 'frontend_base_url' => 'https://preview.example.test', + 'api_base_url' => 'https://api-preview.example.test', + 'versions' => releaseStatusReadyVersions(12), + 'availability' => [ + 'configured' => true, + 'status' => 'ready', + 'missing' => [], + ], + ], + ], + 'deployment_targets' => [ + ['id' => 72, 'channel_id' => 5, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-preview'], + ['id' => 73, 'channel_id' => 5, 'app' => 'api', 'coolify_service_uuid' => 'api-preview'], + ], + 'service_sets' => [ + [ + 'id' => 74, + 'channel_id' => 5, + 'mode' => 'isolated_stack', + 'stack' => [ + 'database' => $database, + 'redis' => releaseStatusReadyService('redis', 75), + 'minio' => releaseStatusReadyService('minio', 76), + ], + ], + ], + ]); + + $channel = releaseStatusChannelBySlug($overview, 'preview'); + $databaseRow = releaseStatusServiceByKey($channel, 'database'); + + expect($databaseRow)->toMatchArray([ + 'severity' => 'critical', + 'state' => 'service_unhealthy', + 'issue_type' => 'service_unhealthy', + 'coolify_target_id' => 71, + ]) + ->and($channel['issues'][0])->toMatchArray([ + 'severity' => 'critical', + 'type' => 'service_unhealthy', + 'service_key' => 'database', + ]) + ->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('reconcile_coolify_target') + ->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('redeploy_coolify_target') + ->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('restart_coolify_target'); +}); diff --git a/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php new file mode 100644 index 00000000..f4caff61 --- /dev/null +++ b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php @@ -0,0 +1,1448 @@ + 'Bearer secret-token', + 'nested' => [ + 'api_key' => 'key-value', + 'safe' => 'visible', + 'items' => [ + ['password' => 'hidden', 'status' => 500], + ], + ], + ]; + + expect(release_manager::redactPayload($payload))->toBe([ + 'Authorization' => '[redacted]', + 'nested' => [ + 'api_key' => '[redacted]', + 'safe' => 'visible', + 'items' => [ + ['password' => '[redacted]', 'status' => 500], + ], + ], + ]); +}); + +it('verifies GitHub sha256 webhook signatures', function (): void { + $secret = 'release-webhook-secret'; + $payload = '{"ref":"refs/heads/main"}'; + $signature = 'sha256=' . hash_hmac('sha256', $payload, $secret); + + expect(release_manager::verifyGithubSignature($secret, $payload, $signature))->toBeTrue(); + expect(release_manager::verifyGithubSignature($secret, $payload, 'sha256=bad'))->toBeFalse(); + expect(release_manager::verifyGithubSignature('', $payload, $signature))->toBeFalse(); +}); + +it('verifies CI release gate bearer tokens from dedicated release credentials', function (): void { + $previous = getenv('RELEASE_MANAGER_GATE_TOKEN'); + + try { + putenv('RELEASE_MANAGER_GATE_TOKEN=release-gate-secret'); + $_SERVER['RELEASE_MANAGER_GATE_TOKEN'] = 'release-gate-secret'; + + $manager = new release_manager(); + + expect($manager->verifyReleaseGateToken('release-gate-secret'))->toBeTrue(); + expect($manager->verifyReleaseGateToken('wrong-secret'))->toBeFalse(); + expect($manager->verifyReleaseGateToken(''))->toBeFalse(); + } finally { + if ($previous === false) { + putenv('RELEASE_MANAGER_GATE_TOKEN'); + unset($_SERVER['RELEASE_MANAGER_GATE_TOKEN']); + } else { + putenv('RELEASE_MANAGER_GATE_TOKEN=' . $previous); + $_SERVER['RELEASE_MANAGER_GATE_TOKEN'] = $previous; + } + } +}); + +it('normalizes app-specific release gate auto-sync metadata', function (): void { + $manager = new release_manager(); + $normalizeGate = new ReflectionMethod(release_manager::class, 'normalizeReleaseGateInput'); + $normalizeGate->setAccessible(true); + $appMatches = new ReflectionMethod(release_manager::class, 'releaseGateAppMatches'); + $appMatches->setAccessible(true); + + $gate = $normalizeGate->invoke($manager, [ + 'channel_slug' => 'stable', + 'app' => 'api', + 'repository' => 'https://github.com/copenhagentruckwash/api.git', + 'branch' => 'master', + 'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8', + 'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123', + 'auto_sync' => true, + ], ['slug' => 'stable']); + + expect($gate)->toMatchArray([ + 'channel_slug' => 'stable', + 'route_slug' => 'master', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + 'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8', + 'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123', + 'auto_sync' => true, + ]); + + expect($appMatches->invoke($manager, ['app' => 'api'], 'api'))->toBeTrue(); + expect($appMatches->invoke($manager, ['apps' => ['frontend', 'api']], 'api'))->toBeTrue(); + expect($appMatches->invoke($manager, [], 'frontend'))->toBeTrue(); + expect($appMatches->invoke($manager, [], 'api'))->toBeFalse(); +}); + +it('requires non-empty release gate checks before auto-sync can proceed', function (): void { + $manager = new release_manager(); + $method = new ReflectionMethod(release_manager::class, 'releaseGateAutoSyncValidationSteps'); + $method->setAccessible(true); + + $failed = $method->invoke($manager, [ + 'channel_slug' => 'stable', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + 'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8', + 'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123', + 'required_checks' => [], + ], ['slug' => 'stable']); + expect($failed)->toContainEqual(expect()->toMatchArray([ + 'step_key' => 'auto_sync_required_checks', + 'status' => 'failed', + ])); + + $passed = $method->invoke($manager, [ + 'channel_slug' => 'stable', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + 'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8', + 'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123', + 'required_checks' => ['api_gateway'], + ], ['slug' => 'stable']); + expect($passed)->toContainEqual(expect()->toMatchArray([ + 'step_key' => 'auto_sync_inputs', + 'status' => 'passed', + ])); +}); + +it('normalizes GitHub repository identifiers for private repository access checks', function (): void { + expect(release_manager::normalizeGithubRepositoryName('truckwash/backend-php'))->toBe('truckwash/backend-php'); + expect(release_manager::normalizeGithubRepositoryName('https://github.com/truckwash/front-end-vue.git'))->toBe('truckwash/front-end-vue'); + expect(release_manager::normalizeGithubRepositoryName('git@github.com:truckwash/backend-php.git'))->toBe('truckwash/backend-php'); + expect(release_manager::normalizeGithubRepositoryName('not a repository'))->toBe(''); +}); + +it('keeps GitHub commit timestamps in public release manager commit payloads', function (): void { + $manager = new release_manager(); + $method = new ReflectionMethod(release_manager::class, 'publicGithubCommit'); + $method->setAccessible(true); + + $commit = $method->invoke($manager, [ + 'sha' => 'feedface00000000000000000000000000000000', + 'html_url' => 'https://github.com/truckwash/backend-php/commit/feedface', + 'commit' => [ + 'message' => "Deploy release bundle\n\nBody is intentionally omitted from option labels.", + 'author' => [ + 'name' => 'Release Bot', + 'date' => '2026-05-19T08:10:00Z', + ], + ], + ]); + + expect($commit)->toMatchArray([ + 'sha' => 'feedface00000000000000000000000000000000', + 'short_sha' => 'feedface0000', + 'message' => 'Deploy release bundle', + 'author_name' => 'Release Bot', + 'authored_at' => '2026-05-19T08:10:00Z', + ]); +}); + +it('summarizes failed deployments and blocks promotion until a deployment succeeds', function (): void { + $summary = release_manager::deploymentFailureSummary( + new RuntimeException('Coolify API request failed: HTTP 404'), + [ + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + 'commit_sha' => 'ab31cd6dbb288606a24fcdbcb7bb7d58d843e4f3', + 'coolify_service_uuid' => 'api-service', + ] + ); + + expect($summary['category'])->toBe('coolify_target'); + expect($summary['stage'])->toBe('provider_target'); + expect($summary['promotion_blocked'])->toBeTrue(); + expect($summary['evidence']['commit_sha'])->toBe('ab31cd6dbb288606a24fcdbcb7bb7d58d843e4f3'); + expect(release_manager::deploymentCanBePromoted('deployed'))->toBeTrue(); + expect(release_manager::deploymentCanBePromoted('failed'))->toBeFalse(); + + $reason = release_manager::deploymentPromotionBlockedReason([ + 'status' => 'failed', + 'result_json' => json_encode(['failure_summary' => $summary]), + ]); + + expect($reason)->toContain('Deployment failed'); + expect($reason)->toContain('HTTP 404'); +}); + +it('uses the selected Coolify project and resolves server UUID from the instance default', function (): void { + $manager = new release_manager(); + $method = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload'); + $method->setAccessible(true); + + $payload = $method->invoke($manager, [ + 'channel_slug' => 'internal', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + ], [ + 'coolify_project_uuid' => 'project-selected', + 'coolify_deploy_now' => true, + 'image' => 'ghcr.io/copenhagentruckwash/api:master', + ], [ + 'default_project_uuid' => 'project-default', + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-default', + ]); + + expect($payload['project_uuid'])->toBe('project-selected'); + expect($payload['server_uuid'])->toBe('server-default'); + expect($payload['environment_name'])->toBe('internal'); + expect($payload)->not->toHaveKey('environment_uuid'); + expect($payload)->not->toHaveKey('coolify_server_uuid'); +}); + +it('supports isolated stack mode and names new Coolify services explicitly', function (): void { + $manager = new release_manager(); + + $normalizeMode = new ReflectionMethod(release_manager::class, 'normalizeServiceSetMode'); + $normalizeMode->setAccessible(true); + expect($normalizeMode->invoke($manager, ' isolated_stack '))->toBe('isolated_stack'); + + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload'); + $payloadMethod->setAccessible(true); + $payload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'internal', + 'app' => 'frontend', + 'repository' => 'copenhagentruckwash/front-end-vue', + 'branch' => 'main', + ], [ + 'coolify_service_name' => 'release-internal-safe-stack-frontend', + 'coolify_project_uuid' => 'project-internal', + 'coolify_deploy_now' => true, + 'image' => 'ghcr.io/copenhagentruckwash/front-end-vue:main', + ], [ + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-default', + ]); + + expect($payload['name'])->toBe('release-internal-safe-stack-frontend'); + expect($payload['project_uuid'])->toBe('project-internal'); + expect($payload['environment_name'])->toBe('internal'); + expect($payload)->not->toHaveKey('type'); + expect(base64_decode($payload['docker_compose_raw'], true))->toContain('ghcr.io/copenhagentruckwash/front-end-vue:main'); +}); + +it('creates frontend Coolify GitHub App application payloads with the release Dockerfile', function (): void { + $manager = new release_manager(); + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload'); + $payloadMethod->setAccessible(true); + + $payload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'canary', + 'app' => 'frontend', + 'repository' => 'copenhagentruckwash/pleno-vue', + 'branch' => 'release/canary', + 'auto_deploy' => 1, + ], [ + 'coolify_service_name' => 'release-canary-pleno-vue', + 'coolify_project_uuid' => 'project-canary', + 'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github', + 'coolify_build_pack' => 'nixpacks', + 'coolify_deploy_now' => true, + 'coolify_public_url' => 'https://canary.example.test', + 'coolify_enable_ssl' => true, + ], [ + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-default', + ]); + + expect($payload['name'])->toBe('release-canary-pleno-vue'); + expect($payload['project_uuid'])->toBe('project-canary'); + expect($payload['environment_name'])->toBe('release-canary'); + expect($payload['server_uuid'])->toBe('server-default'); + expect($payload['github_app_uuid'])->toBe('github-app-copenhagentruckwash-github'); + expect($payload['git_repository'])->toBe('copenhagentruckwash/pleno-vue'); + expect($payload['git_branch'])->toBe('release/canary'); + expect($payload['build_pack'])->toBe('dockerfile'); + expect($payload['ports_exposes'])->toBe('80'); + expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-frontend'); + expect($payload['domains'])->toBe('https://canary.example.test/canary/frontend'); + expect($payload)->not->toHaveKey('publish_directory'); + expect($payload)->not->toHaveKey('is_static'); + expect($payload)->not->toHaveKey('docker_compose_raw'); +}); + +it('uses the self-contained Coolify API Dockerfile for API applications', function (): void { + $manager = new release_manager(); + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload'); + $payloadMethod->setAccessible(true); + + $payload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'internal', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + 'auto_deploy' => 1, + ], [ + 'coolify_service_name' => 'release-internal-api-node3-truckwash-io', + 'coolify_project_uuid' => 'project-internal', + 'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github', + 'coolify_deploy_now' => true, + 'coolify_public_url' => 'https://api-v2.truckwash.io', + 'coolify_enable_ssl' => true, + 'gateway_route_autoprovision' => true, + ], [ + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-node3', + ]); + + expect($payload['build_pack'])->toBe('dockerfile'); + expect($payload['ports_exposes'])->toBe('80'); + expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-api'); + expect($payload['domains'])->toBe('https://api-v2.truckwash.io:80'); + expect($payload)->not->toHaveKey('publish_directory'); + expect($payload)->not->toHaveKey('is_static'); +}); + +it('builds explicit Coolify application route labels for release API targets', function (): void { + $manager = new release_manager(); + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationRoutePayload'); + $payloadMethod->setAccessible(true); + + $payload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'internal', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + ], [ + 'coolify_ports_exposes' => '8080', + ], 'https://api-v2.truckwash.io', 'api-app-uuid', base64_encode('custom.keep=true')); + $labels = explode("\n", base64_decode($payload['custom_labels'], true)); + + expect($payload['domains'])->toBe('https://api-v2.truckwash.io:8080') + ->and($payload['is_force_https_enabled'])->toBeTrue() + ->and($payload['force_domain_override'])->toBeTrue() + ->and($labels)->toContain('custom.keep=true') + ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)') + ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.priority=1001') + ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt') + ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io') + ->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080'); +}); + +it('updates existing frontend Coolify applications away from legacy Nixpacks detection', function (): void { + $manager = new release_manager(); + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationUpdatePayload'); + $payloadMethod->setAccessible(true); + + $payload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'internal', + 'app' => 'frontend', + 'repository' => 'copenhagentruckwash/pleno-vue', + 'branch' => 'master', + 'commit_sha' => '1132c8c2560e44478d1bb777c88c762a5e1d0b20', + ], [ + 'coolify_build_pack' => 'nixpacks', + ]); + + expect($payload['git_repository'])->toBe('copenhagentruckwash/pleno-vue'); + expect($payload['git_branch'])->toBe('master'); + expect($payload['git_commit_sha'])->toBe('1132c8c2560e44478d1bb777c88c762a5e1d0b20'); + expect($payload['build_pack'])->toBe('dockerfile'); + expect($payload['ports_exposes'])->toBe('80'); + expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-frontend'); + expect($payload['install_command'])->toBe(''); + expect($payload['build_command'])->toBe(''); + expect($payload['publish_directory'])->toBe(''); + expect($payload['is_static'])->toBeFalse(); + expect($payload['is_spa'])->toBeFalse(); +}); + +it('can use the Coolify instance default GitHub App when source targets do not store it yet', function (): void { + $manager = new release_manager(); + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload'); + $payloadMethod->setAccessible(true); + + $payload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'internal', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + ], [ + 'coolify_project_uuid' => 'project-internal', + 'coolify_deploy_now' => true, + ], [ + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-default', + 'default_github_app_uuid' => 'github-app-copenhagentruckwash-github', + ]); + + expect($payload['github_app_uuid'])->toBe('github-app-copenhagentruckwash-github'); + expect($payload['git_repository'])->toBe('copenhagentruckwash/api'); + expect($payload['build_pack'])->toBe('dockerfile'); + expect($payload['ports_exposes'])->toBe('80'); + expect($payload)->not->toHaveKey('docker_compose_raw'); +}); + +it('does not treat an existing Coolify service as an application just because a GitHub App UUID is stored', function (): void { + $manager = new release_manager(); + $resourceTypeMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyResourceType'); + $resourceTypeMethod->setAccessible(true); + + expect($resourceTypeMethod->invoke($manager, [ + 'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github', + ], 'existing-service-uuid'))->toBe('service'); + + expect($resourceTypeMethod->invoke($manager, [ + 'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github', + ], ''))->toBe('application'); + + expect($resourceTypeMethod->invoke($manager, [ + 'coolify_resource_type' => 'application', + 'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github', + ], 'existing-application-uuid'))->toBe('application'); +}); + +it('auto-prepares path-routed release targets for Coolify application creation', function (): void { + $manager = new release_manager(); + $needsApplication = new ReflectionMethod(release_manager::class, 'releaseTargetNeedsApplicationAutoCreate'); + $needsApplication->setAccessible(true); + + $target = [ + 'id' => 42, + 'channel_slug' => 'stable', + 'app' => 'api', + 'coolify_instance_id' => 7, + 'coolify_service_uuid' => '', + 'deploy_context_json' => null, + ]; + + expect($needsApplication->invoke($manager, $target, []))->toBeTrue(); + expect($needsApplication->invoke($manager, array_replace($target, [ + 'coolify_service_uuid' => 'existing-service', + ]), []))->toBeFalse(); + expect($needsApplication->invoke($manager, array_replace($target, [ + 'coolify_instance_id' => null, + ]), []))->toBeFalse(); + expect($needsApplication->invoke($manager, $target, [ + 'coolify_auto_create' => true, + ]))->toBeFalse(); +}); + +it('offers application target preparation for missing Coolify service creation failures', function (): void { + $needsApplicationAction = new ReflectionMethod(release_manager::class, 'releaseStatusIssueNeedsApplicationTarget'); + $needsApplicationAction->setAccessible(true); + + expect($needsApplicationAction->invoke(null, [ + 'message' => 'API deployment failed before activation.', + 'next_action' => 'Select an existing Coolify service or enable Coolify service creation before deployment.', + ]))->toBeTrue(); + expect($needsApplicationAction->invoke(null, [ + 'message' => 'Path-routed release targets require a Coolify application resource with StripPrefix labels.', + 'next_action' => 'Migrate this target before deploying.', + ]))->toBeTrue(); + expect($needsApplicationAction->invoke(null, [ + 'message' => 'Release gate failed.', + 'next_action' => 'Run live smoke tests before promotion.', + ]))->toBeFalse(); +}); + +it('builds API Coolify runtime environment from allowed process variables', function (): void { + $keys = ['CONFIG_DB_HOST', 'CONFIG_DB_PASSWORD', 'EDGE_BROKER_URL', 'CORS', 'PATH']; + $previous = []; + foreach ($keys as $key) { + $previous[$key] = getenv($key); + } + + try { + putenv('CONFIG_DB_HOST=db.example.test'); + $_ENV['CONFIG_DB_HOST'] = 'db.example.test'; + $_SERVER['CONFIG_DB_HOST'] = 'db.example.test'; + putenv('CONFIG_DB_PASSWORD=runtime-secret'); + $_ENV['CONFIG_DB_PASSWORD'] = 'runtime-secret'; + $_SERVER['CONFIG_DB_PASSWORD'] = 'runtime-secret'; + putenv('EDGE_BROKER_URL=https://edge.example.test'); + $_ENV['EDGE_BROKER_URL'] = 'https://edge.example.test'; + $_SERVER['EDGE_BROKER_URL'] = 'https://edge.example.test'; + putenv('CORS=https://truckwash.io,https://api-v2.truckwash.io/master/api'); + $_ENV['CORS'] = 'https://truckwash.io,https://api-v2.truckwash.io/master/api'; + $_SERVER['CORS'] = 'https://truckwash.io,https://api-v2.truckwash.io/master/api'; + putenv('PATH=/should/not/copy'); + $_ENV['PATH'] = '/should/not/copy'; + $_SERVER['PATH'] = '/should/not/copy'; + + $manager = new release_manager(); + $runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv'); + $runtimeEnv->setAccessible(true); + + $env = $runtimeEnv->invoke($manager, [ + 'app' => 'api', + ], [ + 'coolify_env' => [ + 'CONFIG_DB_HOST' => 'context-db.example.test', + 'CUSTOM_ALLOWED' => 'from-context', + ], + ]); + + expect($env['USE_ENV'])->toBe('true'); + expect($env['CONFIG_DB_HOST'])->toBe('context-db.example.test'); + expect($env['CONFIG_DB_PASSWORD'])->toBe('runtime-secret'); + expect($env['EDGE_BROKER_URL'])->toBe('https://edge.example.test'); + expect($env['CUSTOM_ALLOWED'])->toBe('from-context'); + expect(explode(',', $env['CORS']))->toContain('https://api-v2.truckwash.io'); + expect(explode(',', $env['CORS']))->toContain('http://localhost:5173'); + expect(explode(',', $env['CORS']))->not->toContain('https://api-v2.truckwash.io/master/api'); + expect($env)->not->toHaveKey('PATH'); + } finally { + foreach ($previous as $key => $value) { + if ($value === false) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + } else { + putenv($key . '=' . $value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } + } + } +}); + +it('resolves backend commit sha from API runtime environment in priority order', function (): void { + $keys = ['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA']; + $previous = []; + foreach ($keys as $key) { + $previous[$key] = [ + 'process' => getenv($key), + 'env_set' => array_key_exists($key, $_ENV), + 'env' => $_ENV[$key] ?? null, + 'server_set' => array_key_exists($key, $_SERVER), + 'server' => $_SERVER[$key] ?? null, + ]; + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + } + + $set = static function (string $key, string $value): void { + putenv($key . '=' . $value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + }; + + try { + $set('API_COMMIT_SHA', 'not-a-sha'); + $set('COMMIT_SHA', 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'); + $set('GITHUB_SHA', 'cccccccccccccccccccccccccccccccccccccccc'); + + expect(release_manager::backendCommitSha())->toBe('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); + + $set('API_COMMIT_SHA', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + + expect(release_manager::backendCommitSha())->toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + } finally { + foreach ($previous as $key => $state) { + if ($state['process'] === false) { + putenv($key); + } else { + putenv($key . '=' . $state['process']); + } + + if ($state['env_set']) { + $_ENV[$key] = $state['env']; + } else { + unset($_ENV[$key]); + } + + if ($state['server_set']) { + $_SERVER[$key] = $state['server']; + } else { + unset($_SERVER[$key]); + } + } + } +}); + +it('injects selected API commit into Coolify runtime env unless explicitly set', function (): void { + $manager = new release_manager(); + $runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv'); + $runtimeEnv->setAccessible(true); + + $selectedCommit = '1111111111111111111111111111111111111111'; + $explicitCommit = '2222222222222222222222222222222222222222'; + + $env = $runtimeEnv->invoke($manager, [ + 'app' => 'api', + 'commit_sha' => $selectedCommit, + ], [ + 'coolify_env' => [ + 'COMMIT_SHA' => $explicitCommit, + ], + ]); + + expect($env['API_COMMIT_SHA'])->toBe($selectedCommit); + expect($env['COMMIT_SHA'])->toBe($explicitCommit); +}); + +it('keeps beta API runtime environment on production database target', function (): void { + $keys = ['CONFIG_DB_TARGET', 'CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', 'DEBUG']; + $previous = []; + foreach ($keys as $key) { + $previous[$key] = getenv($key); + } + + try { + putenv('CONFIG_DB_TARGET=production'); + $_ENV['CONFIG_DB_TARGET'] = 'production'; + $_SERVER['CONFIG_DB_TARGET'] = 'production'; + putenv('CONFIG_DB_HOST=prod-db.example.test'); + $_ENV['CONFIG_DB_HOST'] = 'prod-db.example.test'; + $_SERVER['CONFIG_DB_HOST'] = 'prod-db.example.test'; + putenv('CONFIG_DB_DEBUG_HOST=debug-db.example.test'); + $_ENV['CONFIG_DB_DEBUG_HOST'] = 'debug-db.example.test'; + $_SERVER['CONFIG_DB_DEBUG_HOST'] = 'debug-db.example.test'; + putenv('DEBUG=false'); + $_ENV['DEBUG'] = 'false'; + $_SERVER['DEBUG'] = 'false'; + + $manager = new release_manager(); + $runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv'); + $runtimeEnv->setAccessible(true); + + $env = $runtimeEnv->invoke($manager, [ + 'app' => 'api', + 'channel_slug' => 'beta', + ], []); + + expect($env['CONFIG_DB_TARGET'])->toBe('production'); + expect($env['CONFIG_DB_HOST'])->toBe('prod-db.example.test'); + expect($env['DEBUG'])->toBe('false'); + expect($env['CONFIG_DB_TARGET'])->not->toBe('debug'); + } finally { + foreach ($previous as $key => $value) { + if ($value === false) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + } else { + putenv($key . '=' . $value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } + } + } +}); + +it('treats attach-existing service sets without data target ids as production-shared ready', function (): void { + $manager = new release_manager(); + $status = new ReflectionMethod(release_manager::class, 'serviceSetStatus'); + $status->setAccessible(true); + $dataTargets = ['database' => null, 'redis' => null, 'minio' => null]; + + expect($status->invoke($manager, 'attach_existing', 10, 11, $dataTargets))->toBe('ready'); + expect($status->invoke($manager, 'clone_existing', 10, 11, $dataTargets))->toBe('needs_clone_targets'); + expect($status->invoke($manager, 'fresh_empty', 10, 11, $dataTargets))->toBe('isolated_empty'); + expect($status->invoke($manager, 'isolated_stack', 10, 11, $dataTargets))->toBe('needs_isolated_targets'); +}); + +it('detects explicit data target ids so beta service sets can stay data-only', function (): void { + $manager = new release_manager(); + $hasExplicitDataTargets = new ReflectionMethod(release_manager::class, 'serviceSetInputHasExplicitDataTargets'); + $hasExplicitDataTargets->setAccessible(true); + + expect($hasExplicitDataTargets->invoke($manager, [ + 'mode' => 'attach_existing', + 'data_source_service_set_id' => 10, + ]))->toBeFalse(); + expect($hasExplicitDataTargets->invoke($manager, [ + 'data_targets' => [ + 'database' => 42, + ], + ]))->toBeTrue(); + expect($hasExplicitDataTargets->invoke($manager, [ + 'redis_coolify_target_id' => 43, + ]))->toBeTrue(); +}); + +it('allows beta production-service bundles only when data services stay production-shared', function (): void { + $manager = new release_manager(); + $assert = new ReflectionMethod(release_manager::class, 'assertBetaProductionDataPolicy'); + $assert->setAccessible(true); + $betaChannel = ['id' => 2, 'slug' => 'beta']; + + expect($assert->invoke($manager, $betaChannel, ['mode' => 'attach_existing']))->toBeNull(); + + foreach (['clone_existing', 'fresh_empty', 'isolated_stack'] as $mode) { + expect(fn() => $assert->invoke($manager, $betaChannel, ['mode' => $mode])) + ->toThrow(RuntimeException::class, 'production-shared'); + } +}); + +it('keeps release branch services out of the production Coolify environment', function (): void { + $manager = new release_manager(); + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload'); + $payloadMethod->setAccessible(true); + + $canaryPayload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'canary', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'release/canary', + ], [ + 'coolify_project_uuid' => 'project-release', + 'coolify_deploy_now' => true, + 'environment_name' => 'production', + 'image' => 'ghcr.io/copenhagentruckwash/api:canary', + ], [ + 'default_environment_uuid' => 'env-production', + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-default', + ]); + + expect($canaryPayload['environment_name'])->toBe('release-canary'); + expect($canaryPayload)->not->toHaveKey('environment_uuid'); + + $betaPayload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'beta', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'release/beta', + ], [ + 'coolify_project_uuid' => 'project-release', + 'coolify_deploy_now' => true, + 'image' => 'ghcr.io/copenhagentruckwash/api:beta', + ], [ + 'default_environment_uuid' => 'env-production', + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-default', + ]); + + expect($betaPayload['environment_name'])->toBe('release-beta'); + expect($betaPayload)->not->toHaveKey('environment_uuid'); +}); + +it('does not invent GHCR images for Coolify service payloads', function (): void { + $manager = new release_manager(); + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload'); + $payloadMethod->setAccessible(true); + + $payloadMethod->invoke($manager, [ + 'channel_slug' => 'internal', + 'app' => 'frontend', + 'repository' => 'copenhagentruckwash/pleno-vue', + 'branch' => 'master', + ], [ + 'coolify_service_name' => 'release-internal-safe-stack-frontend', + 'coolify_project_uuid' => 'project-internal', + 'coolify_deploy_now' => true, + ], [ + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-default', + ]); +})->throws(RuntimeException::class, 'explicit image'); + +it('creates Coolify service payloads from raw compose without a service type', function (): void { + $manager = new release_manager(); + $payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload'); + $payloadMethod->setAccessible(true); + + $compose = "services:\n app:\n image: ghcr.io/copenhagentruckwash/api:test"; + $payload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'internal', + 'app' => 'api', + 'repository' => 'copenhagentruckwash/api', + 'branch' => 'master', + ], [ + 'coolify_project_uuid' => 'project-internal', + 'coolify_deploy_now' => true, + 'docker_compose_raw' => $compose, + ], [ + 'default_environment_name' => 'production', + 'default_server_uuid' => 'server-default', + ]); + + expect($payload)->not->toHaveKey('type'); + expect($payload['docker_compose_raw'])->toBe(base64_encode($compose)); + expect(base64_decode($payload['docker_compose_raw'], true))->toBe($compose); +}); + +it('defines release manager schema, routes, permissions, and system-status integration hooks', function (): void { + $schema = file_get_contents(app_path('classes/release_manager_schema_bootstrap.php')); + $manager = file_get_contents(app_path('classes/release_manager.php')); + $route = file_get_contents(app_path('routes/releaseManagerRoute.php')); + $auth = file_get_contents(app_path('routes/authRoute.php')); + $response = file_get_contents(app_path('classes/response.php')); + $status = file_get_contents(app_path('classes/superuser_system_status_service.php')); + $index = file_get_contents(app_path('index.php')); + $corsPolicy = file_get_contents(app_path('classes/cors_policy.php')); + + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_channels'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_versions'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_channel_versions'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_assignments'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployment_targets'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_auto_sync_events'); + expect($schema)->toContain('UNIQUE KEY uq_release_auto_sync_event'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_service_sets'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployments'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_bundles'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_operation_runs'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_operation_steps'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_replay_targets'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_sessions'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_events'); + expect($schema)->toContain('device_type VARCHAR(16) NULL'); + expect($schema)->toContain('frontend_version_label VARCHAR(128) NULL'); + expect($schema)->toContain('api_version_label VARCHAR(128) NULL'); + expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_module_health_snapshots'); + expect($schema)->toContain("ensureColumn('release_channel_versions', 'service_set_id'"); + expect($schema)->toContain("ensureColumn('release_channel_versions', 'bundle_id'"); + expect($schema)->toContain("ensureColumn('release_deployments', 'deployment_kind'"); + expect($schema)->toContain("ensureColumn('release_deployments', 'active_channel_app_key'"); + expect($schema)->toContain("ensureUniqueIndex('release_deployments', 'uniq_release_deployments_active_channel_app'"); + expect($schema)->toContain("ensureColumn('release_timeline_sessions', 'device_type'"); + expect($schema)->toContain("ensureIndex('release_timeline_sessions', 'idx_release_timeline_device'"); + expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'enabled'"); + expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'release_gate_token'"); + expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'release_gate_required_for_promotion'"); + expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'github_token'"); + expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'github_api_url'"); + expect($schema)->toContain("['stable', 'Stable'"); + expect($schema)->toContain("['canary', 'Canary'"); + expect($schema)->toContain("['beta', 'Beta'"); + expect($schema)->toContain("['internal', 'Internal'"); + + expect($route)->toContain('/release/bootstrap'); + expect($route)->toContain('/release/runtime'); + expect($route)->toContain('/release/timeline/events'); + expect($route)->toContain('/release/github/webhook'); + expect($route)->toContain('/release/gate/test-runs'); + expect($route)->toContain('releaseGateToken'); + expect($route)->toContain('/superuser/releases/config'); + expect($route)->toContain('/superuser/releases/github/repositories'); + expect($route)->toContain('/superuser/releases/github/branches'); + expect($route)->toContain('/superuser/releases/github/commits'); + expect($route)->toContain('/superuser/releases/github/test'); + expect($route)->toContain('/superuser/releases/channels'); + expect($route)->toContain('/superuser/releases/channels/{id}/sync'); + expect($route)->toContain('/superuser/releases/test-runs'); + expect($route)->toContain('/superuser/releases/operations'); + expect($route)->toContain('/superuser/releases/operations/{id}'); + expect($route)->toContain('/superuser/releases/channels/{id}/bundle'); + expect($route)->toContain('/superuser/releases/assignment-subjects'); + expect($route)->toContain('/superuser/releases/assignments'); + expect($route)->toContain('/superuser/releases/service-sets'); + expect($route)->toContain("\$this->delete('/superuser/releases/service-sets/{id}'"); + expect($route)->toContain('/superuser/releases/service-sets/{id}/isolated-data-services'); + expect($route)->toContain('/superuser/releases/bundles'); + expect($route)->toContain('/superuser/releases/bundles/{id}/deploy'); + expect($route)->toContain('/superuser/releases/bundles/{id}/promote'); + expect($route)->toContain('/superuser/releases/deployments'); + expect($route)->toContain('/superuser/releases/issues/actions'); + expect($route)->toContain('/superuser/releases/replay-targets'); + expect($route)->toContain('/superuser/releases/timeline/sessions'); + expect($route)->toContain('/superuser/releases/timeline/sessions/{traceId}'); + expect($route)->toContain('/superuser/releases/timeline'); + expect($route)->toContain("requirePermission('superuser_release_manager_view')"); + expect($route)->toContain("requirePermission('superuser_release_manager_manage')"); + expect($route)->toContain("requirePermission('superuser_release_manager_deploy')"); + expect($route)->toContain("requirePermission('superuser_release_manager_rollback')"); + expect($route)->toContain("requirePermission('superuser_release_manager_replay')"); + + expect($manager)->toContain('verifyGithubSignature'); + expect($manager)->toContain('verifyReleaseGateToken'); + expect($manager)->toContain('normalizeReleaseGateInput'); + expect($manager)->toContain('processReleaseGateAutoSync'); + expect($manager)->toContain('release_auto_sync_events'); + expect($manager)->toContain('require_readiness'); + expect($manager)->toContain('release-manifest.json'); + expect($manager)->toContain('static_artifact'); + expect($manager)->toContain('api_gateway'); + expect($manager)->toContain('assertReleaseGatePassedForPromotion'); + expect($manager)->toContain('release_gate_required_for_promotion'); + expect($manager)->toContain("'public_smoke_required' => true"); + expect($manager)->toContain('normalizeGithubRepositoryName'); + expect($manager)->toContain("private const DEFAULT_BRANCH = 'master'"); + expect($manager)->toContain('releaseConfig'); + expect($manager)->toContain('updateReleaseConfig'); + expect($manager)->toContain('github_token_variable'); + expect($manager)->toContain('github_token_env_variable'); + expect($manager)->toContain('listGithubRepositories'); + expect($manager)->toContain('listGithubBranches'); + expect($manager)->toContain('listGithubCommits'); + expect($manager)->toContain('testGithubRepositoryAccess'); + expect($manager)->toContain('githubRepositoryAccess'); + expect($manager)->toContain('github_token'); + expect($manager)->toContain('commit_mode'); + expect($manager)->toContain('restartCoolifyService'); + expect($manager)->toContain('deployCoolifyReleaseTarget'); + expect($manager)->toContain('deployResource'); + expect($manager)->toContain('releaseCoolifyGitCommitSha'); + expect($manager)->toContain('releaseCoolifyForceRebuild'); + expect($manager)->toContain('listServiceSets'); + expect($manager)->toContain('createServiceSet'); + expect($manager)->toContain('deleteServiceSet'); + expect($manager)->toContain('createBundle'); + expect($manager)->toContain('deployBundle'); + expect($manager)->toContain('promoteBundle'); + expect($manager)->toContain('setChannelBundle'); + expect($manager)->toContain('searchAssignmentSubjects'); + expect($manager)->toContain('publicAssignmentSubjectSuggestion'); + expect($manager)->toContain('available_channels'); + expect($manager)->toContain("'source' => 'deployment'"); + expect($manager)->toContain('chooseRuntimeChannel'); + expect($manager)->toContain('requestedRuntimeChannelSlug'); + expect($manager)->toContain("status = 'superseded'"); + expect($manager)->toContain('serviceSetIsActive'); + expect($manager)->toContain('bundleIsActive'); + expect($manager)->toContain('service_set_removed'); + expect($manager)->toContain('clone_replica_from_source'); + expect($manager)->toContain('register_isolated_empty_service'); + expect($manager)->toContain('isolated_stack'); + expect($manager)->toContain('create_isolated_empty_stack_service'); + expect($manager)->toContain('assertIsolatedStackTarget'); + expect($manager)->toContain('completeIsolatedStackDataServices'); + expect($manager)->toContain('createIsolatedStackDataTarget'); + expect($manager)->toContain('skip_replication_provisioning'); + expect($manager)->toContain('must not point at an existing Coolify service'); + expect($manager)->toContain("'data_promotion' => false"); + expect($manager)->toContain("'replica_failover' => false"); + expect($manager)->toContain('deploymentCanBePromoted'); + expect($manager)->toContain('deploymentFailureSummary'); + expect($manager)->toContain('runIssueAction'); + expect($manager)->toContain('release_issue_action_attempted'); + expect($manager)->toContain('prepareReleaseIssueApplicationTarget'); + expect($manager)->toContain('coolifyProjectSuggestions'); + expect($manager)->toContain('releaseCoolifyServerUuid'); + expect($manager)->toContain('coolify_project_uuid'); + expect($manager)->toContain('releaseCoolifyServicePayload'); + expect($manager)->toContain('releaseCoolifyApplicationPayload'); + expect($manager)->toContain('releaseCoolifyApplicationUpdatePayload'); + expect($manager)->toContain('createPrivateGithubAppApplication'); + expect($manager)->toContain('coolifyGithubAppSuggestions'); + expect($manager)->toContain('coolify_github_apps'); + expect($manager)->toContain('release_deployment_targets'); + expect($manager)->toContain('resolveChannel'); + expect($manager)->toContain('capturePolicyFor'); + expect($manager)->toContain('listTimelineSessions'); + expect($manager)->toContain('timelineSessionDetail'); + expect($manager)->toContain('timelineSessionContext'); + expect($manager)->toContain('timelineErrorReports'); + expect($manager)->toContain('timelineReleaseContext'); + expect($manager)->toContain('publicTimelineDeploymentReference'); + expect($manager)->toContain('publicTimelineBundleReference'); + expect($manager)->toContain('cleanupExpiredReplayData'); + expect($manager)->toContain('channelAvailability'); + expect($manager)->toContain("'availability' =>"); + expect($manager)->toContain('redactPayload'); + expect($manager)->toContain('moduleKeys'); + expect($manager)->toContain('releaseSuggestions'); + expect($manager)->toContain('load_balancer_domains'); + expect($manager)->toContain('appendDomainSuggestion'); + expect($manager)->toContain('Coolify SSL requires a DNS domain routed to the load balancer.'); + expect($manager)->toContain('releaseRuntimeUrls'); + expect($manager)->toContain('coolify_services'); + expect($manager)->toContain('coolify_enable_ssl'); + expect($manager)->toContain('createService'); + expect($manager)->toContain('updateService'); + expect(file_get_contents(app_path('classes/coolify_api_client.php')))->toContain('updateApplicationEnvsBulk'); + expect($manager)->toContain('channel_presets'); + expect($manager)->toContain('target_presets'); + + expect($auth)->toContain("'release' => (new release_manager())->runtimeForPayload"); + expect($response)->toContain('recordBackendFailure'); + expect($status)->toContain("'key' => 'releasemanager'"); + expect($status)->toContain('probeReleaseManagerModule'); + expect($index)->toContain('release_manager::initializeRequestContext'); + expect($corsPolicy)->toContain('X-Release-Trace'); + expect($manager)->toContain('X-Release-Channel'); + expect($manager)->toContain('normalizeReleaseApiIngressPath'); + expect($manager)->toContain('routeSlugForChannel'); + expect($manager)->toContain('channelSlugForRoute'); + expect($manager)->toContain('syncChannel'); + expect($manager)->toContain('runReleaseTest'); + expect($manager)->toContain('releaseTestAppsFromInput'); + expect($manager)->toContain('release_operation_runs'); + expect($manager)->toContain('active_channel_app_key'); + expect($manager)->toContain('production_shared'); + expect($manager)->toContain('Path-routed release targets require a Coolify application resource with StripPrefix labels'); + expect($manager)->toContain('$applicationPayload[\'instant_deploy\'] = false'); + expect($manager)->toContain('$servicePayload[\'instant_deploy\'] = false'); +}); + +it('captures release request context from headers and runtime query parameters', function (): void { + $server = $_SERVER; + $get = $_GET; + $context = $GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null; + + try { + $_SERVER['HTTP_X_RELEASE_TRACE'] = 'trace-abc123'; + $_SERVER['HTTP_X_RELEASE_CHANNEL'] = 'Internal'; + $_SERVER['HTTP_X_FRONTEND_VERSION'] = 'frontend-130cc2fc106a'; + $_SERVER['REQUEST_URI'] = '/release/runtime?release_channel=canary'; + $_GET = ['release_channel' => 'canary']; + + $initialized = release_manager::initializeRequestContext(); + + expect($initialized)->toMatchArray([ + 'trace_id' => 'trace-abc123', + 'requested_channel' => 'internal', + 'frontend_version' => 'frontend-130cc2fc106a', + 'original_request_uri' => '/release/runtime?release_channel=canary', + 'ingress_prefix_stripped' => false, + ]); + expect($GLOBALS['RELEASE_REQUEST_CONTEXT']['requested_channel'])->toBe('internal'); + } finally { + $_SERVER = $server; + $_GET = $get; + if ($context === null) { + unset($GLOBALS['RELEASE_REQUEST_CONTEXT']); + } else { + $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context; + } + } +}); + +it('normalizes channel-prefixed API ingress paths before route dispatch', function (): void { + $server = $_SERVER; + $get = $_GET; + $context = $GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null; + + try { + $_SERVER['REQUEST_URI'] = '/internal/api/auth/session?foo=bar'; + $_GET = ['foo' => 'bar']; + $GLOBALS['RELEASE_REQUEST_CONTEXT'] = [ + 'trace_id' => 'trace-normalize', + 'requested_channel' => '', + 'frontend_version' => '', + 'backend_version' => 'test', + 'request_started_at' => date('c'), + 'original_request_uri' => '/internal/api/auth/session?foo=bar', + 'normalized_request_uri' => '', + 'ingress_prefix_stripped' => false, + ]; + + $normalized = release_manager::normalizeReleaseApiIngressPath(['stable', 'internal']); + + expect($normalized)->toMatchArray([ + 'channel_slug' => 'internal', + 'original_request_uri' => '/internal/api/auth/session?foo=bar', + 'normalized_request_uri' => '/auth/session?foo=bar', + 'normalized_path' => '/auth/session', + ]); + expect($_SERVER['REQUEST_URI'])->toBe('/auth/session?foo=bar'); + expect($_SERVER['PATH_INFO'])->toBe('/auth/session'); + expect($_GET['release_channel'])->toBe('internal'); + expect($GLOBALS['RELEASE_REQUEST_CONTEXT'])->toMatchArray([ + 'requested_channel' => 'internal', + 'normalized_request_uri' => '/auth/session?foo=bar', + 'ingress_prefix_stripped' => true, + ]); + } finally { + $_SERVER = $server; + $_GET = $get; + if ($context === null) { + unset($GLOBALS['RELEASE_REQUEST_CONTEXT']); + } else { + $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context; + } + } +}); + +it('maps the public master API prefix to the stable release channel', function (): void { + $server = $_SERVER; + $get = $_GET; + $context = $GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null; + + try { + $_SERVER['REQUEST_URI'] = '/master/api/release/runtime'; + $_GET = []; + $GLOBALS['RELEASE_REQUEST_CONTEXT'] = [ + 'trace_id' => 'trace-master', + 'requested_channel' => '', + 'frontend_version' => '', + 'backend_version' => 'test', + 'request_started_at' => date('c'), + 'original_request_uri' => '/master/api/release/runtime', + 'normalized_request_uri' => '', + 'ingress_prefix_stripped' => false, + ]; + + $normalized = release_manager::normalizeReleaseApiIngressPath(['stable', 'beta', 'canary', 'internal']); + + expect($normalized)->toMatchArray([ + 'route_slug' => 'master', + 'channel_slug' => 'stable', + 'normalized_request_uri' => '/release/runtime', + 'normalized_path' => '/release/runtime', + ]); + expect($_GET['release_channel'])->toBe('stable'); + expect($GLOBALS['RELEASE_REQUEST_CONTEXT'])->toMatchArray([ + 'requested_channel' => 'stable', + 'release_route_slug' => 'master', + 'ingress_prefix_stripped' => true, + ]); + } finally { + $_SERVER = $server; + $_GET = $get; + if ($context === null) { + unset($GLOBALS['RELEASE_REQUEST_CONTEXT']); + } else { + $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context; + } + } +}); + +it('uses master as the public route slug for the stable release channel', function (): void { + expect(release_manager::routeSlugForChannel('stable'))->toBe('master'); + expect(release_manager::routeSlugForChannel('beta'))->toBe('beta'); + expect(release_manager::channelSlugForRoute('master'))->toBe('stable'); + expect(release_manager::channelSlugForRoute('internal'))->toBe('internal'); +}); + +it('ignores explicit runtime selection for channels outside the principal channel set', function (): void { + $manager = new release_manager(); + $chooseRuntimeChannel = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel'); + $chooseRuntimeChannel->setAccessible(true); + + $stable = [ + 'id' => 1, + 'slug' => 'stable', + 'enabled' => 1, + 'default_channel' => 1, + ]; + $internal = [ + 'id' => 4, + 'slug' => 'internal', + 'name' => 'Internal', + 'enabled' => 1, + 'default_channel' => 0, + ]; + + expect($chooseRuntimeChannel->invoke($manager, $stable, [$stable], 'internal'))->toBe($stable); + expect($chooseRuntimeChannel->invoke($manager, $stable, [$stable, $internal], 'internal'))->toBe($internal); +}); + +it('requires non-default release channel runtime URLs and preserves load balancer paths', function (): void { + $manager = new release_manager(); + $availability = new ReflectionMethod(release_manager::class, 'channelAvailability'); + $availability->setAccessible(true); + $runtimeUrls = new ReflectionMethod(release_manager::class, 'releaseRuntimeUrls'); + $runtimeUrls->setAccessible(true); + $publicUrl = new ReflectionMethod(release_manager::class, 'releaseCoolifyPublicUrl'); + $publicUrl->setAccessible(true); + $targetPublicBaseUrl = new ReflectionMethod(release_manager::class, 'releaseTargetPublicBaseUrl'); + $targetPublicBaseUrl->setAccessible(true); + $applicationLabels = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationLabels'); + $applicationLabels->setAccessible(true); + + expect($availability->invoke($manager, [ + 'id' => 1, + 'slug' => 'stable', + 'default_channel' => 1, + 'frontend_base_url' => null, + 'api_base_url' => null, + ]))->toMatchArray([ + 'configured' => true, + 'missing' => [], + 'status' => 'ready', + ]); + + expect($runtimeUrls->invoke($manager, [ + 'id' => 2, + 'slug' => 'canary', + 'default_channel' => 0, + 'frontend_base_url' => null, + 'api_base_url' => null, + ], [ + 'frontend' => ['deployed_url' => 'https://api-v2.truckwash.io/canary/frontend/health'], + 'api' => ['deployed_url' => 'https://api-v2.truckwash.io/canary/api/ping'], + ]))->toBe([ + 'frontend_base_url' => 'https://api-v2.truckwash.io/canary/frontend', + 'api_base_url' => 'https://api-v2.truckwash.io/canary/api', + ]); + + expect($runtimeUrls->invoke($manager, [ + 'id' => 3, + 'slug' => 'internal', + 'default_channel' => 0, + 'frontend_base_url' => null, + 'api_base_url' => null, + ], [ + 'frontend' => ['deployed_url' => null], + 'api' => ['deployed_url' => null], + 'service_set' => [ + 'targets' => [ + 'frontend' => [ + 'app' => 'frontend', + 'channel_slug' => 'internal', + 'deploy_context' => [ + 'coolify_public_url' => 'https://api-v2.truckwash.io', + ], + ], + 'api' => [ + 'app' => 'api', + 'channel_slug' => 'internal', + 'deploy_context' => [ + 'coolify_domain' => 'api-v2.truckwash.io', + ], + ], + ], + ], + ]))->toBe([ + 'frontend_base_url' => 'https://api-v2.truckwash.io/internal/frontend', + 'api_base_url' => 'https://api-v2.truckwash.io/internal/api', + ]); + + expect($publicUrl->invoke($manager, ['app' => 'frontend', 'channel_slug' => 'canary'], [ + 'coolify_public_url' => 'https://api-v2.truckwash.io/canary/frontend', + 'coolify_enable_ssl' => true, + ]))->toBe('https://api-v2.truckwash.io/canary/frontend'); + + expect($publicUrl->invoke($manager, ['app' => 'frontend', 'channel_slug' => 'internal'], [ + 'coolify_public_url' => 'https://api-v2.truckwash.io', + 'coolify_enable_ssl' => true, + ]))->toBe('https://api-v2.truckwash.io/internal/frontend'); + + expect($publicUrl->invoke($manager, ['app' => 'api', 'channel_slug' => 'internal'], [ + 'coolify_domain' => 'api-v2.truckwash.io', + 'coolify_enable_ssl' => true, + ]))->toBe('https://api-v2.truckwash.io/internal/api'); + + expect($targetPublicBaseUrl->invoke($manager, [ + 'app' => 'frontend', + 'channel_slug' => 'internal', + 'deploy_context_json' => json_encode([ + 'coolify_public_url' => 'https://api-v2.truckwash.io', + ]), + ]))->toBe('https://api-v2.truckwash.io/internal/frontend'); + + $labels = $applicationLabels->invoke( + null, + 'https://api-v2.truckwash.io/internal/api', + 'release-api-internal', + 80, + 'letsencrypt' + ); + expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)'); + expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.priority=1013'); + expect($labels)->toContain('traefik.http.middlewares.https-0-release-api-internal-stripprefix.stripprefix.prefixes=/internal/api'); + expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.middlewares=https-0-release-api-internal-stripprefix,gzip'); + + $source = file(app_path('classes/release_manager.php')); + $methodSource = implode('', array_slice( + $source, + $availability->getStartLine() - 1, + $availability->getEndLine() - $availability->getStartLine() + 1 + )); + + expect($methodSource)->toContain('frontend_base_url'); + expect($methodSource)->toContain('api_base_url'); + expect($methodSource)->not->toContain("missing[] = 'release_bundle'"); + expect($methodSource)->toContain('frontend_version'); + expect($methodSource)->toContain('api_version'); +}); + +it('resolves release deployment endpoints from manual overrides, URLs, health checks, and gateway defaults', function (): void { + $manager = new release_manager(); + $endpoint = new ReflectionMethod(release_manager::class, 'releaseDeploymentEndpoint'); + $endpoint->setAccessible(true); + $publicUrl = new ReflectionMethod(release_manager::class, 'releaseCoolifyPublicUrl'); + $publicUrl->setAccessible(true); + $targetPublicBaseUrl = new ReflectionMethod(release_manager::class, 'releaseTargetPublicBaseUrl'); + $targetPublicBaseUrl->setAccessible(true); + + $manual = $endpoint->invoke($manager, [ + 'app' => 'api', + 'channel_slug' => 'beta', + 'deploy_context' => [ + 'endpoint_mode' => 'manual', + 'manual_endpoint_host' => 'manual-api.example.test', + 'manual_endpoint_port' => '8443', + ], + ]); + + expect($manual)->toMatchArray([ + 'mode' => 'manual', + 'status' => 'resolved', + 'host' => 'manual-api.example.test', + 'port' => 8443, + 'url' => 'https://manual-api.example.test:8443', + 'source' => 'manual', + ]); + expect($publicUrl->invoke($manager, ['app' => 'api', 'channel_slug' => 'beta'], [ + 'endpoint_mode' => 'manual', + 'manual_endpoint_host' => 'manual-api.example.test', + 'manual_endpoint_port' => '8443', + ]))->toBe('https://manual-api.example.test:8443'); + + $fromPublicUrl = $endpoint->invoke($manager, [ + 'app' => 'frontend', + 'channel_slug' => 'internal', + 'deploy_context' => [ + 'endpoint_mode' => 'auto', + 'coolify_public_url' => 'https://api-v2.truckwash.io', + ], + ]); + expect($fromPublicUrl)->toMatchArray([ + 'mode' => 'auto', + 'status' => 'resolved', + 'host' => 'api-v2.truckwash.io', + 'port' => 443, + 'url' => 'https://api-v2.truckwash.io/internal/frontend', + 'source' => 'coolify_public_url', + ]); + + $fromHealth = $endpoint->invoke($manager, [ + 'app' => 'api', + 'channel_slug' => 'canary', + 'health_url' => 'https://health.example.test/canary/api/ping', + 'deploy_context' => [ + 'endpoint_mode' => 'auto', + 'coolify_domain' => 'domain.example.test', + ], + ]); + expect($fromHealth)->toMatchArray([ + 'status' => 'resolved', + 'host' => 'health.example.test', + 'url' => 'https://health.example.test/canary/api', + 'source' => 'health_url', + ]); + + $gateway = $endpoint->invoke($manager, [ + 'app' => 'frontend', + 'channel_slug' => 'beta', + 'deploy_context_json' => json_encode([ + 'endpoint_mode' => 'auto', + 'public_gateway_host' => 'gateway.example.test', + ]), + ]); + expect($gateway)->toMatchArray([ + 'mode' => 'auto', + 'status' => 'pending', + 'host' => 'gateway.example.test', + 'port' => 443, + 'url' => 'https://gateway.example.test/beta/frontend', + 'source' => 'auto_gateway', + ]); + expect($targetPublicBaseUrl->invoke($manager, [ + 'app' => 'frontend', + 'channel_slug' => 'beta', + 'deploy_context_json' => json_encode([ + 'endpoint_mode' => 'auto', + 'public_gateway_host' => 'gateway.example.test', + ]), + ]))->toBe('https://gateway.example.test/beta/frontend'); +}); + +it('exposes release version git commit metadata for runtime channel cards', function (): void { + $manager = new release_manager(); + $publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion'); + $publicVersion->setAccessible(true); + + $version = $publicVersion->invoke($manager, [ + 'id' => 12, + 'app' => 'frontend', + 'repository' => 'truckwash/front-end-vue', + 'branch' => 'release/canary', + 'commit_sha' => 'c0ffee0000001111222233334444555566667777', + 'tag' => null, + 'version_label' => 'frontend-canary', + 'build_url' => null, + 'artifact_url' => null, + 'deployed_url' => null, + 'status' => 'active', + 'metadata_json' => json_encode([ + 'github_access' => [ + 'commit' => [ + 'sha' => 'c0ffee0000001111222233334444555566667777', + 'authored_at' => '2026-05-19T10:15:00Z', + ], + ], + ]), + 'created_at' => '2026-05-19 10:10:00', + 'deployed_at' => '2026-05-19 10:20:00', + ]); + + expect($version['commit_sha'])->toBe('c0ffee0000001111222233334444555566667777'); + expect($version['commit']['sha'])->toBe('c0ffee0000001111222233334444555566667777'); + expect($version['commit_authored_at'])->toBe('2026-05-19T10:15:00Z'); + expect($version['deployed_at'])->toBe('2026-05-19 10:20:00'); +}); + +it('only chooses requested runtime channels from channels available to the principal', function (): void { + $manager = new release_manager(); + $choose = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel'); + $choose->setAccessible(true); + + $stable = [ + 'id' => 1, + 'slug' => 'stable', + 'name' => 'Stable', + 'default_channel' => 1, + ]; + $canary = [ + 'id' => 2, + 'slug' => 'canary', + 'name' => 'Canary', + 'default_channel' => 0, + ]; + $internal = [ + 'id' => 3, + 'slug' => 'internal', + 'name' => 'Internal', + 'default_channel' => 0, + 'enabled' => 1, + ]; + + expect($choose->invoke($manager, $stable, [$stable, $canary], 'canary'))->toBe($canary); + expect($choose->invoke($manager, $stable, [$stable, $canary], 'stable'))->toBe($stable); + expect($choose->invoke($manager, $stable, [$stable, $canary], 'internal'))->toBe($stable); + expect($choose->invoke($manager, $stable, [$stable, $canary], 'unknown'))->toBe($stable); + expect($choose->invoke($manager, $canary, [$stable, $canary], ''))->toBe($canary); + expect($internal['enabled'])->toBe(1); +}); + +it('normalizes release assignment subject suggestions without leaking private fields', function (): void { + $suggestion = release_manager::publicAssignmentSubjectSuggestion([ + 'subject_type' => 'USER', + 'subject_id' => 42, + 'title' => ' Dispatcher ', + 'description' => 'Customer #424242 / dispatcher@example.test', + 'icon' => 'fas fa-user', + 'source' => 'users', + 'password' => 'secret', + 'two_factor_secret' => 'private', + ]); + + expect($suggestion)->toBe([ + 'subject_type' => 'user', + 'subject_id' => '42', + 'label' => 'Dispatcher - Customer #424242 / dispatcher@example.test', + 'title' => 'Dispatcher', + 'description' => 'Customer #424242 / dispatcher@example.test', + 'icon' => 'fas fa-user', + 'source' => 'users', + ]); + expect(array_keys($suggestion))->not->toContain('password'); + expect(array_keys($suggestion))->not->toContain('two_factor_secret'); + expect(release_manager::publicAssignmentSubjectSuggestion([ + 'subject_type' => 'invalid', + 'subject_id' => 42, + 'title' => 'Invalid', + ]))->toBeNull(); +}); diff --git a/services/nginx/app/tests/Unit/Replication/ReplicaFailoverManagerTest.php b/services/nginx/app/tests/Unit/Replication/ReplicaFailoverManagerTest.php new file mode 100644 index 00000000..8f07203f --- /dev/null +++ b/services/nginx/app/tests/Unit/Replication/ReplicaFailoverManagerTest.php @@ -0,0 +1,236 @@ + $id, + 'kind' => $kind, + 'label' => $kind . ' replica ' . $id, + 'host' => $kind . '-replica-' . $id, + 'port' => match ($kind) { + 'database' => 3306, + 'redis' => 6379, + 'minio' => 9000, + default => 1, + }, + 'database_name' => $kind === 'database' ? 'truckwash' : null, + 'database_index' => $kind === 'redis' ? 0 : null, + 'username' => $kind === 'minio' ? 'access-key' : 'app', + 'password_secret' => 'secret', + 'admin_username' => '', + 'admin_password_secret' => '', + 'role' => 'replica', + 'status' => 'ok', + 'options_json' => $kind === 'minio' + ? json_encode(['endpoint' => 'http://minio-replica-' . $id . ':9000', 'buckets' => ['attachments']]) + : null, + 'last_status_json' => json_encode([ + 'status' => 'ok', + 'replication_percent' => 100, + 'blockers' => [], + 'checked_at' => $checkedAt, + ]), + 'last_checked_at' => $checkedAt, + 'deleted_at' => null, + ]; + + return array_replace($base, $overrides); +} + +it('normalizes failover config defaults and per-kind enablement', function (): void { + $defaults = replica_failover_manager::normalizeConfig([]); + + expect($defaults)->toMatchArray([ + 'enabled' => false, + 'database_enabled' => false, + 'redis_enabled' => false, + 'minio_enabled' => false, + 'max_status_age_seconds' => 90, + ]); + + $config = replica_failover_manager::normalizeConfig([ + 'enabled' => 'true', + 'database_enabled' => '1', + 'redis_enabled' => false, + 'minio_enabled' => 'yes', + 'max_status_age_seconds' => '120', + ]); + + expect(replica_failover_manager::kindEnabled($config, 'database'))->toBeTrue(); + expect(replica_failover_manager::kindEnabled($config, 'redis'))->toBeFalse(); + expect(replica_failover_manager::kindEnabled($config, 'minio'))->toBeTrue(); + expect($config['max_status_age_seconds'])->toBe(120); +}); + +it('requires strict fresh 100 percent replica status for candidates', function (): void { + $now = time(); + $fresh = failover_test_host('database', 2, date('c', $now - 30)); + $stale = failover_test_host('database', 3, date('c', $now - 120)); + $notCaughtUp = failover_test_host('database', 4, date('c', $now - 10), [ + 'last_status_json' => json_encode([ + 'status' => 'ok', + 'replication_percent' => 99.99, + 'blockers' => [], + 'checked_at' => date('c', $now - 10), + ]), + ]); + $blocked = failover_test_host('database', 5, date('c', $now - 10), [ + 'last_status_json' => json_encode([ + 'status' => 'degraded', + 'replication_percent' => 100, + 'blockers' => ['lagging'], + 'checked_at' => date('c', $now - 10), + ]), + ]); + + expect(replica_failover_manager::snapshotHostIsStrictlyFresh($fresh, 90, $now))->toBeTrue(); + expect(replica_failover_manager::snapshotHostIsStrictlyFresh($stale, 90, $now))->toBeFalse(); + expect(replica_failover_manager::snapshotHostIsStrictlyFresh($notCaughtUp, 90, $now))->toBeFalse(); + expect(replica_failover_manager::snapshotHostIsStrictlyFresh($blocked, 90, $now))->toBeFalse(); +}); + +it('selects the freshest eligible replica for failover', function (): void { + $now = time(); + $older = failover_test_host('redis', 2, date('c', $now - 40)); + $newer = failover_test_host('redis', 3, date('c', $now - 10)); + $wrongKind = failover_test_host('minio', 4, date('c', $now - 5)); + + $candidate = replica_failover_manager::snapshotFailoverCandidate([$older, $newer, $wrongKind], 'redis', 90, $now); + + expect($candidate['id'])->toBe(3); +}); + +it('promotes enabled startup dependencies from snapshot in dependency order', function (): void { + $path = tempnam(sys_get_temp_dir(), 'failover-snapshot-'); + $events = []; + $now = time(); + + replication_bootstrap_config::writeSnapshot([ + 'active' => [ + 'database' => ['host' => 'db-primary', 'database' => 'truckwash', 'user' => 'app', 'password_secret' => 'secret'], + 'redis' => ['host' => 'redis-primary', 'database' => 0, 'user' => '', 'password_secret' => 'secret'], + 'minio' => ['endpoint' => 'http://minio-primary:9000', 'access_key' => 'access-key', 'secret_key_secret' => 'secret'], + ], + 'failover' => [ + 'config' => [ + 'enabled' => true, + 'database_enabled' => true, + 'redis_enabled' => true, + 'minio_enabled' => true, + 'max_status_age_seconds' => 90, + ], + 'hosts' => [ + 'database' => [failover_test_host('database', 11, date('c', $now - 10))], + 'redis' => [failover_test_host('redis', 12, date('c', $now - 10))], + 'minio' => [failover_test_host('minio', 13, date('c', $now - 10))], + ], + ], + ], $path); + + try { + $result = replica_failover_manager::applyStartupFailoverFromSnapshot($path, [ + 'primary_down' => function (string $kind) use (&$events): bool { + $events[] = 'primary:' . $kind; + return true; + }, + 'candidate_reachable' => function (string $kind) use (&$events): bool { + $events[] = 'reachable:' . $kind; + return true; + }, + 'promote_candidate' => function (string $kind) use (&$events): void { + $events[] = 'promote:' . $kind; + }, + ]); + + $snapshot = replication_bootstrap_config::loadSnapshot($path); + + expect($result['changed'])->toBeTrue(); + expect($events)->toBe([ + 'primary:database', + 'reachable:database', + 'promote:database', + 'primary:redis', + 'reachable:redis', + 'promote:redis', + 'primary:minio', + 'reachable:minio', + 'promote:minio', + ]); + expect($snapshot['active']['database']['id'])->toBe(11); + expect($snapshot['active']['redis']['id'])->toBe(12); + expect($snapshot['active']['minio']['id'])->toBe(13); + expect($snapshot['pending_failovers'])->toHaveCount(3); + } finally { + @unlink($path); + } +}); + +it('does not promote a disabled dependency during startup failover', function (): void { + $path = tempnam(sys_get_temp_dir(), 'failover-snapshot-'); + $events = []; + + replication_bootstrap_config::writeSnapshot([ + 'active' => [ + 'redis' => ['host' => 'redis-primary', 'database' => 0, 'user' => '', 'password_secret' => 'secret'], + ], + 'failover' => [ + 'config' => [ + 'enabled' => true, + 'redis_enabled' => false, + 'max_status_age_seconds' => 90, + ], + 'hosts' => [ + 'redis' => [failover_test_host('redis', 12, date('c'))], + ], + ], + ], $path); + + try { + $result = replica_failover_manager::applyStartupFailoverFromSnapshot($path, [ + 'primary_down' => function (string $kind) use (&$events): bool { + $events[] = $kind; + return true; + }, + ]); + + expect($result['changed'])->toBeFalse(); + expect($result['results']['redis']['reason'])->toBe('disabled'); + expect($events)->toBe([]); + } finally { + @unlink($path); + } +}); + +it('wires the failover module config endpoint and promotion paths', function (): void { + $route = file_get_contents(app_path('routes/moduleConfigRoute.php')); + $module = file_get_contents(app_path('modules/failover/failover_c.php')); + $enabledConfig = file_get_contents(app_path('modules/failover/config/failover_enabled_c.php')); + $manager = file_get_contents(app_path('classes/replication_manager.php')); + $startup = file_get_contents(app_path('classes/replica_failover_manager.php')); + + expect($route)->toContain("'/failover/config'"); + expect($route)->toContain('modules_failover_config'); + expect($enabledConfig)->toContain("'enabled'"); + expect($module)->toContain('failover_database_enabled_c::class'); + expect($module)->toContain('failover_redis_enabled_c::class'); + expect($module)->toContain('failover_minio_enabled_c::class'); + expect($module)->toContain('failover_max_status_age_seconds_c::class'); + expect($manager)->toContain('promoteDatabaseHostForFailover'); + expect($manager)->toContain('promoteRedisHostForFailover'); + expect($manager)->toContain('promoteMinioHostForFailover'); + expect($manager)->toContain("['REPLICAOF', 'NO', 'ONE']"); + expect($manager)->toContain('runAutomaticFailoverMonitor'); + expect($startup)->toContain('replication_bootstrap_config::loadSnapshot($path)'); + expect($startup)->not->toContain('module_config'); +}); diff --git a/services/nginx/app/tests/Unit/Replication/ReplicationManagerStatusTest.php b/services/nginx/app/tests/Unit/Replication/ReplicationManagerStatusTest.php new file mode 100644 index 00000000..4b15666b --- /dev/null +++ b/services/nginx/app/tests/Unit/Replication/ReplicationManagerStatusTest.php @@ -0,0 +1,696 @@ +toBe(11); + expect(replication_manager::mysqlGtidCoveragePercent($source, $executed))->toBe(72.73); +}); + +it('reports empty source GTID sets as caught up', function (): void { + expect(replication_manager::mysqlGtidCoveragePercent('', ''))->toBe(100.0); +}); + +it('computes Redis offset percentages safely', function (): void { + expect(replication_manager::redisOffsetPercent(1000, 750))->toBe(75.0); + expect(replication_manager::redisOffsetPercent(0, 0))->toBe(100.0); + expect(replication_manager::redisOffsetPercent(1000, 1250))->toBe(100.0); + expect(replication_manager::redisReplicationPercentFromInfo( + ['master_repl_offset' => 1000], + ['slave_repl_offset' => 750] + ))->toBe(75.0); + expect(replication_manager::redisReplicationPercentFromInfo( + ['master_repl_offset' => 1000], + ['master_sync_in_progress' => '1', 'master_sync_total_bytes' => '1000', 'master_sync_left_bytes' => '250'] + ))->toBe(75.0); + expect(replication_manager::redisReplicationPercentFromInfo( + ['master_repl_offset' => 1000], + ['master_sync_in_progress' => '1'] + ))->toBe(5.0); + expect(replication_manager::redisProvisionProgress(0.0))->toBe(5.0); + expect(replication_manager::redisProvisionProgress(100.0, ['Redis replica link to primary is not up.']))->toBe(99.99); + expect(replication_manager::replicationHealthStatus(true, 'replica', 5.0, []))->toBe('degraded'); + expect(replication_manager::replicationHealthStatus(true, 'replica', 5.0, [], false))->toBe('ok'); + expect(replication_manager::replicationHealthStatus(true, 'replica', 100.0, []))->toBe('ok'); +}); + +it('computes MariaDB GTID coverage by domain sequence', function (): void { + $source = '0-1-10,1-1-20'; + $replica = '0-2-8,1-3-20'; + + expect(replication_manager::mariadbGtidCoveragePercent($source, $replica))->toBe(93.33); + expect(replication_manager::mariadbGtidCoveragePercent('', ''))->toBe(100.0); +}); + +it('normalizes public replication kind aliases', function (): void { + expect(replication_manager::normalizeKind('databases'))->toBe('database'); + expect(replication_manager::normalizeKind('mysql'))->toBe('database'); + expect(replication_manager::normalizeKind('redis'))->toBe('redis'); + expect(replication_manager::normalizeKind('minio'))->toBe('minio'); + expect(replication_manager::normalizeKind('s3'))->toBe('minio'); + expect(replication_manager::normalizeKind('object-storage'))->toBe('minio'); +}); + +it('generates replication-ready MariaDB compose templates without embedding secrets', function (): void { + $template = replication_manager::composeTemplate([ + 'kind' => 'database', + 'role' => 'replica', + 'service_name' => 'MariaDB Replica 2', + 'database' => 'nnks_db', + 'username' => 'nnks_db_user', + 'host_port' => 5433, + 'server_id' => 2, + ]); + + expect($template['kind'])->toBe('database'); + expect($template['role'])->toBe('replica'); + expect($template['service_name'])->toBe('mariadb-replica-2'); + expect($template['compose'])->toContain('image: "mariadb:11"'); + expect($template['compose'])->toContain('"--server-id=2"'); + expect($template['compose'])->toContain('"--log-bin=/var/lib/mysql/mariadb-bin"'); + expect($template['compose'])->toContain('"--binlog-format=ROW"'); + expect($template['compose'])->toContain('"--gtid-strict-mode=ON"'); + expect($template['compose'])->toContain('"--read-only=ON"'); + expect($template['compose'])->toContain('"--replicate-ignore-table=nnks_db.logs"'); + expect($template['compose'])->toContain('"--replicate-ignore-table=nnks_db.edge_gateway_log_entries"'); + expect($template['compose'])->toContain('"--replicate-ignore-table=nnks_db.replication_status_snapshots"'); + expect($template['compose'])->toContain('"--replicate-ignore-table=nnks_db.system_search_documents"'); + expect($template['compose'])->toContain('"5433:3306"'); + expect($template['compose'])->toContain('mariadb-replica-2-seed'); + expect($template['compose'])->toContain('MARIADB_PRIMARY_ADMIN_PASSWORD'); + expect($template['compose'])->toContain('mariadb-dump --host="$${MARIADB_PRIMARY_HOST}"'); + expect($template['compose'])->toContain('--ignore-table="$${MARIADB_SEED_DATABASE}.logs"'); + expect($template['compose'])->toContain('--ignore-table="$${MARIADB_SEED_DATABASE}.edge_gateway_log_entries"'); + expect($template['compose'])->toContain('--ignore-table="$${MARIADB_SEED_DATABASE}.replication_status_snapshots"'); + expect($template['compose'])->toContain('--ignore-table="$${MARIADB_SEED_DATABASE}.system_search_documents"'); + expect($template['compose'])->toContain('--no-data "$${MARIADB_SEED_DATABASE}" "$${table}"'); + expect($template['compose'])->toContain('touch "$${marker}"'); + expect($template['compose'])->toContain('${MARIADB_ROOT_PASSWORD:?set MARIADB_ROOT_PASSWORD}'); + expect($template['compose'])->not->toContain(''); + expect($template['env'])->toMatch('/MARIADB_ROOT_PASSWORD=[A-Za-z0-9_-]{32}/'); + expect($template['env'])->toMatch('/MARIADB_PASSWORD=[A-Za-z0-9_-]{32}/'); + expect($template['env'])->toContain('MARIADB_PRIMARY_ADMIN_PASSWORD='); + expect($template['credentials']['password'])->toMatch('/^[A-Za-z0-9_-]{32}$/'); + expect($template['credentials']['admin_password'])->toMatch('/^[A-Za-z0-9_-]{32}$/'); + expect($template['credentials']['port'])->toBe(5433); + expect($template['credentials']['allow_preseeded_replica'])->toBeTrue(); + expect($template['seed_command'])->toContain('mariadb-dump'); + expect($template['seed_command'])->toContain('--gtid'); + expect($template['seed_command'])->toContain('--master-data=2'); + expect($template['seed_command'])->toContain('--ignore-table=\'nnks_db.logs\''); + expect($template['seed_command'])->toContain('--ignore-table=\'nnks_db.edge_gateway_log_entries\''); + expect($template['seed_command'])->toContain('--ignore-table=\'nnks_db.replication_status_snapshots\''); + expect($template['seed_command'])->toContain('--ignore-table=\'nnks_db.system_search_documents\''); +}); + +it('can embed primary admin credentials in generated MariaDB replica env files', function (): void { + $template = replication_manager::composeTemplate([ + 'kind' => 'database', + 'role' => 'replica', + 'primary_admin_username' => 'primary-root', + 'primary_admin_password' => 'primary-secret', + ]); + + expect($template['env'])->toContain('MARIADB_PRIMARY_ADMIN_USER=primary-root'); + expect($template['env'])->toContain('MARIADB_PRIMARY_ADMIN_PASSWORD=primary-secret'); +}); + +it('detects missing database tables before provisioning a preseeded replica', function (): void { + expect(replication_manager::missingDatabaseTables( + ['customers', 'edge_gateways', 'orders'], + ['customers', 'orders'] + ))->toBe(['edge_gateways']); +}); + +it('seeds MariaDB replicas in place instead of requiring container recreation', function (): void { + $content = file_get_contents(app_path('classes/replication_manager.php')); + + expect($content)->toContain('advanceMariaDbReplicaSeed($operationId, $primary, $host, $target)'); + expect($content)->toContain('MARIADB_SEED_BATCH_ROWS'); + expect($content)->toContain('MARIADB_SEED_STEP_SECONDS'); + expect($content)->toContain('activeOperationId($kind, $id, \'provision\')'); + expect($content)->toContain('updateOperationProgress($operationId, $progress, $message, $context)'); + expect($content)->toContain("application_write_freeze::freeze('MariaDB replica seed is copying data.'"); + expect($content)->toContain('DROP DATABASE IF EXISTS'); + expect($content)->toContain('CREATE DATABASE '); + expect($content)->toContain('SHOW CREATE TABLE'); + expect($content)->toContain('SET GLOBAL gtid_slave_pos'); + expect($content)->toContain('databasePrimaryKeyColumnNames($source, $primaryDatabase, $tableName)'); + expect($content)->toContain('$target->begin_transaction()'); + expect($content)->toContain("'connect_without_database' => \$usePreseededReplica"); +}); + +it('keeps operational and derived tables schema-only during MariaDB seeding and replication', function (): void { + $content = file_get_contents(app_path('classes/replication_manager.php')); + + expect($content)->toContain('MARIADB_SCHEMA_ONLY_TABLES'); + expect($content)->toContain("'logs'"); + expect($content)->toContain("'edge_gateway_log_entries'"); + expect($content)->toContain("'replication_status_snapshots'"); + expect($content)->toContain("'replication_operations'"); + expect($content)->toContain("'replication_audit_logs'"); + expect($content)->toContain("'system_search_documents'"); + expect($content)->toContain("'skip_data' => \$skipData"); + expect($content)->toContain('createMariaDbReplicaTable('); + expect($content)->toContain('SET GLOBAL replicate_ignore_table'); + expect($content)->toContain('--replicate-ignore-table='); + expect($content)->toContain('mariaDbSchemaOnlyDumpIgnoreArgs'); + expect($content)->toContain('mariaDbSchemaOnlySeedCommandIgnoreArgs'); + expect($content)->toContain('databaseSchemaOnlyTablesWithRows'); + expect($content)->toContain('schemaOnlyTablesContainRowsBlocker'); + expect($content)->toContain('RESET SLAVE ALL'); + expect($content)->toContain('mariaDbSeedContextRequiresFilterReset($context)'); +}); + +it('allows failed replicas to be removed without allowing primary or healthy replica removal', function (): void { + expect(replication_manager::replicationHostCanBeRemoved(['role' => 'primary', 'status' => 'ok']))->toBeFalse(); + expect(replication_manager::replicationHostCanBeRemoved(['role' => 'inactive', 'status' => 'inactive']))->toBeTrue(); + expect(replication_manager::replicationHostCanBeRemoved(['role' => 'replica', 'status' => 'degraded']))->toBeTrue(); + expect(replication_manager::replicationHostCanBeRemoved(['role' => 'replica', 'status' => 'ok']))->toBeFalse(); + + $content = file_get_contents(app_path('classes/replication_manager.php')); + expect($content)->toContain('coolify_manager::replicationHostCanBeRemoved'); + expect($content)->toContain('coolify_manager::markTargetsRemovedForReplicationHost'); +}); + +it('supports metadata-only replication host renames', function (): void { + $content = file_get_contents(app_path('classes/replication_manager.php')); + + expect($content)->toContain('function renameHost('); + expect($content)->toContain('host_renamed'); + expect($content)->toContain('coolify_manager::syncLabelForReplicationHost'); + expect($content)->toContain('writeBootstrapSnapshot()'); + expect($content)->toContain('Replication host label must be 128 characters or fewer.'); +}); + +it('generates Redis replica compose templates with primary connection placeholders', function (): void { + $template = replication_manager::composeTemplate([ + 'kind' => 'redis', + 'role' => 'replica', + 'service_name' => 'Redis Replica', + 'host_port' => 6380, + 'primary_host' => 'redis-primary.internal', + 'primary_port' => 6379, + ]); + + expect($template['kind'])->toBe('redis'); + expect($template['compose'])->toContain('image: "redis:7"'); + expect($template['compose'])->toContain('REDIS_PASSWORD: "${REDIS_PASSWORD:?set REDIS_PASSWORD}"'); + expect($template['compose'])->toContain('REDIS_PRIMARY_HOST: "${REDIS_PRIMARY_HOST:?set REDIS_PRIMARY_HOST}"'); + expect($template['compose'])->toContain('REDIS_PRIMARY_PORT: "${REDIS_PRIMARY_PORT:-6379}"'); + expect($template['compose'])->toContain('${REDIS_PRIMARY_PASSWORD:?set REDIS_PRIMARY_PASSWORD}'); + expect($template['compose'])->toContain('REDIS_PRIMARY_USERNAME: "${REDIS_PRIMARY_USERNAME:-}"'); + expect($template['compose'])->toContain('if [ ! -f /data/redis.conf ]; then'); + expect($template['compose'])->toContain('> /data/redis.conf'); + expect($template['compose'])->toContain('exec redis-server /data/redis.conf'); + expect($template['compose'])->toContain('echo "replicaof $$REDIS_PRIMARY_HOST $${REDIS_PRIMARY_PORT:-6379}"'); + expect($template['compose'])->toContain('echo "masterauth $$REDIS_PRIMARY_PASSWORD"'); + expect($template['compose'])->toContain('echo "masteruser $$REDIS_PRIMARY_USERNAME"'); + expect($template['compose'])->toContain('redis-cli --no-auth-warning -a \"$${REDIS_PASSWORD}\" ping | grep PONG'); + expect($template['compose'])->not->toContain($template['credentials']['password']); + expect($template['env'])->toMatch('/REDIS_PASSWORD=[A-Za-z0-9_-]{32}/'); + expect($template['env'])->toContain('REDIS_PRIMARY_HOST=redis-primary.internal'); + expect($template['env'])->toContain('REDIS_PRIMARY_PORT=6379'); + expect($template['env'])->toContain("REDIS_PRIMARY_PASSWORD=\n"); + expect($template['env'])->toContain("REDIS_PRIMARY_USERNAME=\n"); + expect($template['credentials']['password'])->toMatch('/^[A-Za-z0-9_-]{32}$/'); +}); + +it('generates MinIO replica compose templates without embedding secrets', function (): void { + $template = replication_manager::composeTemplate([ + 'kind' => 'minio', + 'role' => 'replica', + 'service_name' => 'MinIO Replica 1', + 'host' => 'node2.truckwash.dk', + 'scheme' => 'http', + 'host_port' => 9010, + 'console_port' => 9011, + 'buckets' => ['attachments', 'uploads'], + ]); + + expect($template['kind'])->toBe('minio'); + expect($template['engine'])->toBe('minio'); + expect($template['service_name'])->toBe('minio-replica-1'); + expect($template['host_port'])->toBe(9010); + expect($template['console_port'])->toBe(9011); + expect($template['compose'])->toContain('image: "minio/minio:latest"'); + expect($template['compose'])->toContain('MINIO_ROOT_USER: "${MINIO_ROOT_USER:?set MINIO_ROOT_USER}"'); + expect($template['compose'])->toContain('MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD}"'); + expect($template['compose'])->toContain('MINIO_SERVER_URL: "${MINIO_SERVER_URL:-}"'); + expect($template['compose'])->toContain('MINIO_BROWSER_REDIRECT_URL: "${MINIO_BROWSER_REDIRECT_URL:-}"'); + expect($template['compose'])->toContain('"9010:9000"'); + expect($template['compose'])->toContain('"9011:9001"'); + expect($template['compose'])->toContain('mc mb --with-lock --ignore-existing'); + expect($template['compose'])->toContain('mc version enable'); + expect($template['compose'])->toContain('MINIO_PRIMARY_ENDPOINT'); + expect($template['compose'])->not->toContain($template['credentials']['password']); + expect($template['env'])->toMatch('/MINIO_ROOT_USER=twminio[a-f0-9]{24}/'); + expect($template['env'])->toMatch('/MINIO_ROOT_PASSWORD=[A-Za-z0-9_-]{32}/'); + expect($template['env'])->toContain('MINIO_SERVER_URL=http://node2.truckwash.dk:9010'); + expect($template['env'])->toContain('MINIO_BROWSER_REDIRECT_URL=http://node2.truckwash.dk:9011'); + expect($template['env'])->toContain('MINIO_BUCKETS=attachments,uploads'); + expect($template['env'])->toContain('MINIO_REPLICATION_TRANSFER_LIMIT=25Mi'); + expect($template['env'])->toContain('MINIO_PRIMARY_ENDPOINT='); + expect($template['credentials']['username'])->toMatch('/^twminio[a-f0-9]{24}$/'); + expect($template['credentials']['scheme'])->toBe('http'); + expect($template['credentials']['buckets'])->toBe(['attachments', 'uploads']); + expect($template['credentials']['replication_transfer_limit'])->toBe('25Mi'); + expect($template['credentials']['space_headroom_percent'])->toBe(20.0); +}); + +it('keeps MinIO backup replicas bounded to the recent backup window', function (): void { + $template = replication_manager::composeTemplate([ + 'kind' => 'minio', + 'role' => 'replica', + 'service_name' => 'minio-replica-1', + 'buckets' => ['backups', 'uploads'], + ]); + $content = file_get_contents(app_path('classes/replication_manager.php')); + + expect(replication_manager::minioBackupReplicaRetentionDays())->toBe(30); + expect($template['compose'])->toContain('mc ilm rule add --expire-days "30" --noncurrent-expire-days "30"'); + expect($template['env'])->toContain('MINIO_BACKUP_REPLICA_RETENTION_DAYS=30'); + expect($template['steps'])->toContain('The backups bucket is retained on replicas for 30 days; other buckets are fully replicated.'); + expect($content)->not->toContain('seedMinioReplicaBackupWindow'); + expect($content)->not->toContain("'--newer-than'"); + expect($content)->toContain("'--limit-upload'"); + expect($content)->toContain("'--limit-download'"); + expect($content)->toContain("? 'delete,delete-marker'"); + expect($content)->toContain('putBucketLifecycleConfiguration'); + expect($content)->toContain('listObjectVersions'); + expect($content)->toContain('minioBackupReplicaRetentionConfigured'); + expect(replication_manager::minioBackupRetentionBlockers([ + 'buckets' => [ + ['name' => 'backups', 'expired_objects' => 2], + ], + ]))->toBe([ + 'MinIO backup replica contains 2 backup objects older than 30 days. Run provisioning to prune retained backups.', + ]); +}); + +it('prefills MinIO replica compose primary values from current config when available', function (): void { + $previousMinio = $GLOBALS['MINIO'] ?? null; + + $GLOBALS['MINIO'] = [ + 'endpoint' => 'https://minio-primary.internal:9000', + 'access_key' => 'primary-access', + 'secret_key' => 'primary-secret', + ]; + + try { + $template = replication_manager::composeTemplate([ + 'kind' => 'minio', + 'role' => 'replica', + 'service_name' => 'minio-replica-1', + ]); + + expect($template['env'])->toContain('MINIO_PRIMARY_ENDPOINT=https://minio-primary.internal:9000'); + expect($template['env'])->toContain('MINIO_PRIMARY_ACCESS_KEY=primary-access'); + expect($template['env'])->toContain('MINIO_PRIMARY_SECRET_KEY=primary-secret'); + expect($template['compose'])->not->toContain('primary-secret'); + } finally { + if ($previousMinio === null) { + unset($GLOBALS['MINIO']); + } else { + $GLOBALS['MINIO'] = $previousMinio; + } + } +}); + +it('computes MinIO free-space and catch-up math safely', function (): void { + expect(replication_manager::minioRequiredFreeBytes(1000))->toBe(1200); + expect(replication_manager::minioByteReplicationPercent(1000, 750))->toBe(75.0); + expect(replication_manager::minioByteReplicationPercent(0, 0))->toBe(100.0); + expect(replication_manager::minioProvisionProgress([ + 'replication_percent' => 3.2, + 'raw' => ['storage' => ['measured' => true]], + ]))->toBe(3.2); + expect(replication_manager::minioProvisionProgress([ + 'replication_percent' => 0, + 'raw' => ['storage' => ['measured' => false]], + ]))->toBe(5.0); + expect(replication_manager::minioProvisionProgress([ + 'replication_percent' => 2.5, + 'raw' => ['progress_source' => 'minio_replicate_status'], + ]))->toBe(2.5); + expect(replication_manager::minioProvisionProgress([ + 'replication_percent' => 100, + 'raw' => ['storage' => ['measured' => true]], + ]))->toBe(100.0); + expect(replication_manager::minioProvisionProgress([ + 'replication_percent' => 100, + 'blockers' => ['MinIO replica has not caught up.'], + 'raw' => ['storage' => ['measured' => true]], + ]))->toBe(99.9); + expect(replication_manager::minioReplicationProgressFromStatusOutput([ + 'target' => [ + 'replicated' => ['size' => 750], + 'pending' => ['size' => 250], + ], + ])['replication_percent'])->toBe(75.0); + expect(replication_manager::minioReplicationProgressFromStatusOutput([ + 'objects' => [ + 'completed' => 9, + 'pending' => 1, + ], + ])['replication_percent'])->toBe(90.0); + expect(replication_manager::minioReplicationProgressFromStatusOutput([ + 'status' => 'complete', + ])['replication_percent'])->toBe(100.0); + expect(replication_manager::minioReplicationProgressFromStatusOutput( + '{"replicatedSize": 750, "pendingSize": 250}' + )['replication_percent'])->toBe(75.0); + expect(replication_manager::minioReplicationProgressFromStatusOutput( + '{"replicaSize": 750, "pendingSize": 250}' + )['replication_percent'])->toBe(75.0); + expect(replication_manager::minioReplicationProgressFromStatusOutput( + 'target-a: 100%, target-b: 5%' + )['replication_percent'])->toBe(5.0); + expect(replication_manager::minioReplicationProgressFromStatusOutput( + 'target-a: 100%, target-b: 100%' + )['replication_percent'])->toBe(100.0); + expect(replication_manager::minioReplicationProgressFromStatusOutput([ + 'completedReplicationSize' => 1000, + 'queued' => [ + 'curr' => ['count' => 0, 'bytes' => 0], + 'avg' => ['count' => 42, 'bytes' => 25000000], + 'peak' => ['count' => 100, 'bytes' => 50000000], + ], + ])['replication_percent'])->toBe(100.0); + expect(replication_manager::minioBucketCountsTowardCatchUp('uploads'))->toBeTrue(); + expect(replication_manager::minioBucketCountsTowardCatchUp('backups'))->toBeFalse(); + $boundedBackupProgress = replication_manager::minioCatchUpProgressFromBucketStatuses([ + 'uploads' => ['replication_percent' => 100.0, 'blockers' => [], 'stats' => []], + 'backups' => ['replication_percent' => 99.74, 'blockers' => ['MinIO replica has not caught up.'], 'stats' => []], + ]); + expect($boundedBackupProgress['replication_percent'])->toBe(100.0); + expect($boundedBackupProgress['blockers'])->toBe([]); + expect($boundedBackupProgress['ignored_buckets'])->toBe(['backups']); + $onlyBoundedBackupProgress = replication_manager::minioCatchUpProgressFromBucketStatuses([ + 'backups' => ['replication_percent' => 5.0, 'blockers' => ['MinIO replica has not caught up.'], 'stats' => []], + ]); + expect($onlyBoundedBackupProgress['replication_percent'])->toBe(100.0); + expect($onlyBoundedBackupProgress['basis'])->toBe('bounded_retention_only'); + $liveQueueProgress = replication_manager::minioCatchUpProgressFromBucketStatuses([ + 'uploads' => [ + 'replication_percent' => 99.98, + 'blockers' => ['MinIO replica has not caught up.'], + 'stats' => [ + 'completed_bytes' => 53149249190, + 'pending_bytes' => 3439936, + 'failed_bytes' => 0, + 'total_bytes' => 0, + 'completed_count' => 199368, + 'pending_count' => 7, + 'failed_count' => 0, + 'total_count' => 0, + ], + ], + ]); + expect($liveQueueProgress['replication_percent'])->toBe(100.0); + expect($liveQueueProgress['blockers'])->toBe([]); + expect($liveQueueProgress['live_tolerance']['within_tolerance'])->toBeTrue(); + expect(replication_manager::minioSpaceBlockers(1199, 1200))->toContain('MinIO target does not have enough free space. Required 1200 bytes, available 1199 bytes.'); + expect(replication_manager::minioSpaceBlockers(null, 1200))->toBe([]); + expect(replication_manager::minioSpaceBlockers(1200, 1200))->toBe([]); + expect(replication_manager::minioAvailableBytesFromAdminInfo([ + 'servers' => [ + ['drives' => [['availableSpace' => 4096]]], + ], + ]))->toBe(4096); + expect(replication_manager::normalizeMinioBuckets('Attachments, uploads backups'))->toBe(['attachments', 'uploads', 'backups']); + expect(replication_manager::minioDefaultReplicationTransferLimit())->toBe('25Mi'); + expect(replication_manager::normalizeMinioTransferLimit('25MiB/s'))->toBe('25Mi'); + expect(replication_manager::normalizeMinioTransferLimit('100 MB'))->toBe('100M'); + expect(replication_manager::normalizeMinioTransferLimit('0'))->toBe(''); +}); + +it('allows the MinIO client binary to be configured explicitly', function (): void { + $previous = getenv('MINIO_MC_BINARY'); + putenv('MINIO_MC_BINARY=/opt/minio/mc'); + + try { + $method = new ReflectionMethod(replication_manager::class, 'minioClientBinary'); + $method->setAccessible(true); + + expect($method->invoke(null))->toBe('/opt/minio/mc'); + } finally { + if ($previous === false) { + putenv('MINIO_MC_BINARY'); + } else { + putenv('MINIO_MC_BINARY=' . $previous); + } + } +}); + +it('supports MinIO client runtime fallback configuration', function (): void { + $previousDownloadUrl = getenv('MINIO_MC_DOWNLOAD_URL'); + $previousAutoInstall = getenv('MINIO_MC_AUTO_INSTALL'); + $previousCommandTimeout = getenv('MINIO_MC_COMMAND_TIMEOUT_SECONDS'); + $previousDownloadTimeout = getenv('MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS'); + putenv('MINIO_MC_DOWNLOAD_URL=https://example.test/mc'); + putenv('MINIO_MC_AUTO_INSTALL=0'); + putenv('MINIO_MC_COMMAND_TIMEOUT_SECONDS=3'); + putenv('MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS=4'); + + try { + $downloadUrl = new ReflectionMethod(replication_manager::class, 'minioClientDownloadUrl'); + $downloadUrl->setAccessible(true); + $autoInstall = new ReflectionMethod(replication_manager::class, 'minioClientAutoInstallEnabled'); + $autoInstall->setAccessible(true); + $commandTimeout = new ReflectionMethod(replication_manager::class, 'minioClientCommandTimeoutSeconds'); + $commandTimeout->setAccessible(true); + $downloadTimeout = new ReflectionMethod(replication_manager::class, 'minioClientDownloadTimeoutSeconds'); + $downloadTimeout->setAccessible(true); + $commandLabel = new ReflectionMethod(replication_manager::class, 'minioClientCommandLabel'); + $commandLabel->setAccessible(true); + + expect($downloadUrl->invoke(null))->toBe('https://example.test/mc'); + expect($autoInstall->invoke(null))->toBeFalse(); + expect($commandTimeout->invoke(null))->toBe(3); + expect($downloadTimeout->invoke(null))->toBe(4); + expect($commandLabel->invoke(null, [ + 'alias', + 'set', + 'target', + 'http://minio.example.test:9010', + 'access-key', + 'secret-key', + ]))->toContain('[redacted]'); + expect($commandLabel->invoke(null, [ + 'alias', + 'set', + 'target', + 'http://minio.example.test:9010', + 'access-key', + 'secret-key', + ]))->not->toContain('secret-key'); + } finally { + if ($previousDownloadUrl === false) { + putenv('MINIO_MC_DOWNLOAD_URL'); + } else { + putenv('MINIO_MC_DOWNLOAD_URL=' . $previousDownloadUrl); + } + if ($previousAutoInstall === false) { + putenv('MINIO_MC_AUTO_INSTALL'); + } else { + putenv('MINIO_MC_AUTO_INSTALL=' . $previousAutoInstall); + } + if ($previousCommandTimeout === false) { + putenv('MINIO_MC_COMMAND_TIMEOUT_SECONDS'); + } else { + putenv('MINIO_MC_COMMAND_TIMEOUT_SECONDS=' . $previousCommandTimeout); + } + if ($previousDownloadTimeout === false) { + putenv('MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS'); + } else { + putenv('MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS=' . $previousDownloadTimeout); + } + } +}); + +it('wires MinIO replication through routes and bootstrap snapshots', function (): void { + $manager = file_get_contents(app_path('classes/replication_manager.php')); + $routes = file_get_contents(app_path('routes/superuserReplicationRoute.php')); + $openapi = file_get_contents(app_path('openapi.yaml')); + + expect($manager)->toContain('private const KIND_MINIO'); + expect($manager)->toContain('provisionMinioHost($host, $operationId)'); + expect($manager)->toContain('promoteMinioHost($host)'); + expect($manager)->toContain('testMinioHost($host)'); + expect($manager)->toContain('minioTargetFreeBytes($host)'); + expect($manager)->toContain('minioRequiredFreeBytes'); + expect($manager)->toContain('minioReplicationConfiguredForHosts($primary, $host)'); + expect($manager)->toContain("'--priority',"); + expect($manager)->toContain('minioReplicationTransferLimitArgs'); + expect($manager)->toContain('MINIO_PROGRESS_SCAN_INTERVAL_SECONDS'); + expect($manager)->toContain('MINIO_INCOMPLETE_PROGRESS_MAX_PERCENT'); + expect($manager)->toContain('MINIO_MC_COMMAND_TIMEOUT_SECONDS'); + expect($manager)->toContain('MINIO_MC_DOWNLOAD_TIMEOUT_SECONDS'); + expect($manager)->toContain('proc_terminate($process'); + expect($manager)->toContain("'connect_timeout' => self::MINIO_S3_CONNECT_TIMEOUT_SECONDS"); + expect($manager)->toContain("'retries' => 0"); + expect($manager)->toContain('&& $forceStorageScan;'); + expect($manager)->not->toContain('$isPrimary || $forceStorageScan'); + expect($manager)->toContain('sanitizePublicLastStatus'); + expect($manager)->toContain('MinIO primary object-scan timeouts do not indicate primary availability failure.'); + expect($manager)->toContain('minioProvisionProgress($status)'); + expect($manager)->toContain('MinIO replica is syncing. Copied'); + expect($manager)->toContain("['mb', '--with-lock', '--ignore-existing', 'target/' . \$bucket]"); + expect($manager)->toContain('repairMinioTargetBucketObjectLockIfEmpty($configDir, $target, $bucket, $message)'); + expect($manager)->toContain('minioBucketHasObjects($target, $bucket)'); + expect($manager)->toContain("'skip_storage_scan' => true"); + expect($manager)->toContain('lastStatusReplicationPercent($host, 5.0)'); + expect($manager)->toContain('completeReadyMinioProvisionOperation'); + expect($manager)->toContain('MinIO replication target is caught up.'); + expect($manager)->toContain('shouldAdvanceActiveProvisionDuringRefresh'); + expect($manager)->toContain('provisionHost((string)$host[\'kind\'], (int)$host[\'id\'])'); + expect($manager)->toContain('stale targets do not keep a healthy current target below 100%'); + $normalizedManager = str_replace("\r\n", "\n", $manager); + expect($normalizedManager)->toContain("'replicate',\n 'status',\n 'source/' . \$bucket"); + expect($normalizedManager)->toContain("'replicate',\n 'status',\n '--json',\n 'source/' . \$bucket"); + expect($manager)->toContain('private function minioBucketStats('); + expect($manager)->toContain("'minio' => ["); + expect($routes)->toContain("/superuser/replication/minio"); + expect($openapi)->toContain('enum: [database, redis, minio]'); + expect($openapi)->toContain('endpoint:'); + expect($openapi)->toContain('space_headroom_percent:'); +}); + +it('provisions Redis replicas after a connectivity-only preflight and reports sync progress', function (): void { + $content = file_get_contents(app_path('classes/replication_manager.php')); + + expect($content)->toContain('provisionRedisHost($host, $operationId)'); + expect($content)->toContain("testRedisHost(array_merge(\$host, ['test_connectivity_only' => true]))"); + expect($content)->toContain("executeRaw(['REPLICAOF', (string)\$primary['host'], (string)\$primary['port']])"); + expect($content)->toContain("executeRaw(['CONFIG', 'REWRITE'])"); + expect($content)->toContain('Redis replication was configured; waiting for the replica to catch up.'); + expect($content)->toContain('$onlySyncBlockers'); + expect($content)->toContain('redisProvisionProgress'); + expect($content)->toContain('Redis replication is configured and syncing in the background.'); + expect($content)->toContain('Redis replication is configured, but the replica is waiting for the primary link.'); +}); + +it('keeps Redis promotion caught-up, durable, and metadata-safe', function (): void { + $content = file_get_contents(app_path('classes/replication_manager.php')); + + expect($content)->toContain("application_write_freeze::freeze('Replication promotion in progress.'"); + expect($content)->toContain("if (\$status['blockers'] !== [] || (float)\$status['replication_percent'] < 100.0)"); + expect($content)->toContain("executeRaw(['REPLICAOF', 'NO', 'ONE'])"); + expect($content)->toContain("executeRaw(['CONFIG', 'REWRITE'])"); + expect($content)->toContain('switchPrimary(self::KIND_REDIS'); + expect($content)->toContain('writeBootstrapSnapshot()'); +}); + +it('does not require replica SQL threads before database provisioning configures them', function (): void { + $content = file_get_contents(app_path('classes/replication_manager.php')); + + expect($content)->toContain("'test_connectivity_only' => true"); + expect($content)->toContain("'connect_without_database' => \$usePreseededReplica"); + expect($content)->toContain("'healthy' => \$status['blockers'] === []"); + expect($content)->toContain("'message' => \$targetEngine === 'mariadb'"); + expect($content)->toContain('Database replica status is not configured.'); + expect($content)->toContain('Database replication IO thread is not running.'); + expect($content)->toContain('Database replication SQL thread is not running.'); + expect($content)->toContain("if (\$status !== [])"); +}); + +it('keeps replication operation progress schema idempotent for existing installs', function (): void { + $content = file_get_contents(app_path('classes/replication_schema_bootstrap.php')); + + expect($content)->toContain("ensureColumn('replication_operations', 'progress_percent'"); + expect($content)->toContain("ensureColumn('replication_operations', 'message'"); + expect($content)->toContain("ensureColumn('replication_operations', 'context_json'"); +}); + +it('creates the generated replication user on the primary during provisioning', function (): void { + $content = file_get_contents(app_path('classes/replication_manager.php')); + + expect($content)->toContain('$grantHosts = $this->databaseReplicationGrantHosts($host, $targetStatus);'); + expect($content)->toContain('ensureDatabaseReplicationUser($primary, $replicationUser, $replicationPassword, $grantHosts)'); + expect($content)->toContain('databaseDeniedAccountHostsFromText'); + expect($content)->toContain('Access denied for user'); + expect($content)->toContain('foreach ($grantHosts as $grantHost)'); + expect($content)->toContain('shouldRepairDatabaseReplicationAccess'); + expect($content)->toContain('repairDatabaseReplicationAccess'); + expect($content)->toContain('shouldRepairDatabaseReplicationThreads'); + expect($content)->toContain('repairDatabaseReplicationThreads'); + expect($content)->toContain('refreshDatabaseReplicationConnection'); + expect($content)->toContain("CHANGE MASTER TO MASTER_HOST = '%s', MASTER_PORT = %d, MASTER_USER = '%s', MASTER_PASSWORD = '%s', MASTER_USE_GTID = slave_pos"); + expect($content)->toContain("CHANGE REPLICATION SOURCE TO SOURCE_HOST = '%s', SOURCE_PORT = %d, SOURCE_USER = '%s', SOURCE_PASSWORD = '%s', SOURCE_AUTO_POSITION = 1"); + expect($content)->toContain('restartDatabaseReplicationThreads'); + expect($content)->toContain('databaseOnlyReplicationThreadBlockers'); + expect($content)->toContain('databaseAccountHostGrantCandidates'); + expect($content)->toContain('START SLAVE SQL_THREAD'); + expect($content)->toContain('GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO'); +}); + +it('extracts host-specific MariaDB replication account denials', function (): void { + $extract = new ReflectionMethod(replication_manager::class, 'databaseDeniedAccountHostsFromText'); + $extract->setAccessible(true); + $normalize = new ReflectionMethod(replication_manager::class, 'normalizeDatabaseAccountHost'); + $normalize->setAccessible(true); + $candidates = new ReflectionMethod(replication_manager::class, 'databaseAccountHostGrantCandidates'); + $candidates->setAccessible(true); + + expect($extract->invoke(null, "Access denied for user 'replication'@'10.0.1.13' (using password: YES)")) + ->toBe(['10.0.1.13']); + expect($extract->invoke(null, "Access denied for user 'replication'@'fd9c:738d:4130::d' (using password: YES)")) + ->toBe(['fd9c:738d:4130::d']); + expect($normalize->invoke(null, '10.0.1.13'))->toBe('10.0.1.13'); + expect($normalize->invoke(null, 'bad host;drop'))->toBeNull(); + expect($candidates->invoke(null, '10.0.1.13'))->toBe(['10.0.1.13', '10.0.1.%']); + expect($candidates->invoke(null, 'fd9c:738d:4130::d'))->toBe(['fd9c:738d:4130::d', 'fd9c:738d:4130::%']); +}); + +it('identifies stopped database replication threads as a restartable status', function (): void { + $onlyThreadBlockers = new ReflectionMethod(replication_manager::class, 'databaseOnlyReplicationThreadBlockers'); + $onlyThreadBlockers->setAccessible(true); + + expect($onlyThreadBlockers->invoke(null, [ + 'Database replication IO and SQL threads must both be running.', + 'Database replication IO thread is not running.', + 'Database replication SQL thread is not running.', + ]))->toBeTrue(); + expect($onlyThreadBlockers->invoke(null, [ + 'Database replication IO thread is not running.', + "error reconnecting to master 'replication@23.88.23.183:5432'", + ]))->toBeFalse(); +}); + +it('supports MariaDB prerequisites without requiring Oracle MySQL variables', function (): void { + $blockers = replication_manager::databasePrerequisiteBlockers([ + 'server_version' => '11.8.6-MariaDB-ubu2404', + 'log_bin' => 'ON', + 'server_id' => '12', + 'gtid_binlog_pos' => '0-12-42', + ]); + + expect($blockers)->toBe([]); + + $quietServerBlockers = replication_manager::databasePrerequisiteBlockers([ + 'server_version' => '11.8.6-MariaDB-ubu2404', + 'log_bin' => 'ON', + 'server_id' => '12', + 'gtid_current_pos' => '', + ]); + + expect($quietServerBlockers)->toBe([]); +}); + +it('reports MariaDB-specific blockers when GTID or binary logging prerequisites are missing', function (): void { + $blockers = replication_manager::databasePrerequisiteBlockers([ + 'server_version' => '11.8.6-MariaDB-ubu2404', + 'log_bin' => 'OFF', + 'server_id' => '12', + ]); + + expect($blockers)->toContain('MariaDB binary logging must be enabled.'); + expect($blockers)->toContain('MariaDB GTID position must be available.'); + expect($blockers)->not->toContain('Oracle MySQL 8.x is required for managed replication. Current server reports 11.8.6-MariaDB-ubu2404.'); +}); diff --git a/services/nginx/app/tests/Unit/Replication/ReplicationSecretBoxTest.php b/services/nginx/app/tests/Unit/Replication/ReplicationSecretBoxTest.php new file mode 100644 index 00000000..07720494 --- /dev/null +++ b/services/nginx/app/tests/Unit/Replication/ReplicationSecretBoxTest.php @@ -0,0 +1,78 @@ +previousEncryptionKey = $GLOBALS['ENCRYPTION_KEY'] ?? null; + $GLOBALS['ENCRYPTION_KEY'] = 'unit-test-replication-encryption-key'; +}); + +afterEach(function (): void { + if ($this->previousEncryptionKey === null) { + unset($GLOBALS['ENCRYPTION_KEY']); + return; + } + $GLOBALS['ENCRYPTION_KEY'] = $this->previousEncryptionKey; +}); + +it('encrypts replication secrets without storing plaintext', function (): void { + $secret = replication_secret_box::encrypt('replica-password'); + + expect($secret)->toStartWith('twsec:v1:'); + expect($secret)->not->toContain('replica-password'); + expect(replication_secret_box::decrypt($secret))->toBe('replica-password'); +}); + +it('builds active database and redis config from encrypted bootstrap snapshots', function (): void { + $snapshot = [ + 'active' => [ + 'database' => [ + 'host' => 'mysql-replica.internal', + 'port' => 3307, + 'database' => 'truckwash', + 'user' => 'app', + 'password_secret' => replication_secret_box::encrypt('db-secret'), + 'ssl_mode' => 'REQUIRED', + ], + 'redis' => [ + 'host' => 'redis-replica.internal', + 'port' => 6380, + 'database' => 2, + 'user' => 'default', + 'password_secret' => replication_secret_box::encrypt('redis-secret'), + ], + 'minio' => [ + 'endpoint' => 'https://minio-replica.internal:9000', + 'access_key' => 'minio-access', + 'secret_key_secret' => replication_secret_box::encrypt('minio-secret'), + 'buckets' => ['attachments', 'uploads'], + ], + ], + ]; + + expect(replication_bootstrap_config::activeDatabaseConfigFromSnapshot($snapshot['active']['database']))->toMatchArray([ + 'host' => 'mysql-replica.internal', + 'port' => 3307, + 'database' => 'truckwash', + 'user' => 'app', + 'password' => 'db-secret', + 'ssl_mode' => 'REQUIRED', + ]); + expect(replication_bootstrap_config::activeRedisConfigFromSnapshot($snapshot['active']['redis']))->toMatchArray([ + 'host' => 'redis-replica.internal', + 'port' => 6380, + 'database' => 2, + 'user' => 'default', + 'password' => 'redis-secret', + ]); + expect(replication_bootstrap_config::activeMinioConfigFromSnapshot($snapshot['active']['minio']))->toMatchArray([ + 'endpoint' => 'https://minio-replica.internal:9000', + 'access_key' => 'minio-access', + 'secret_key' => 'minio-secret', + 'buckets' => ['attachments', 'uploads'], + ]); +}); diff --git a/services/nginx/app/tests/Unit/Replication/SuperuserReplicationRouteWiringTest.php b/services/nginx/app/tests/Unit/Replication/SuperuserReplicationRouteWiringTest.php new file mode 100644 index 00000000..896fe8f9 --- /dev/null +++ b/services/nginx/app/tests/Unit/Replication/SuperuserReplicationRouteWiringTest.php @@ -0,0 +1,51 @@ +not->toBeFalse(); + expect($content)->toContain('/superuser/replication'); + expect($content)->toContain('/superuser/replication/databases'); + expect($content)->toContain('/superuser/replication/redis'); + expect($content)->toContain('/superuser/replication/minio'); + expect($content)->toContain('/superuser/replication/compose-template'); + expect($content)->toContain('/superuser/replication/test-credentials'); + expect($content)->toContain('/superuser/replication/{kind}/{id}/test'); + expect($content)->toContain('/superuser/replication/{kind}/{id}/provision'); + expect($content)->toContain('/superuser/replication/{kind}/{id}/promote'); + expect($content)->toContain("\$this->patch('/superuser/replication/{kind}/{id}'"); + expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_view')"); + expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_manage')"); + expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_promote')"); + expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_remove')"); +}); + +it('documents replication management in openapi', function (): void { + $content = file_get_contents(app_path('openapi.yaml')); + + expect($content)->toContain('/superuser/replication:'); + expect($content)->toContain('operationId: getSuperuserReplication'); + expect($content)->toContain('operationId: generateSuperuserReplicationComposeTemplate'); + expect($content)->toContain('operationId: testSuperuserReplicationCredentials'); + expect($content)->toContain('operationId: addSuperuserMinioReplicationHost'); + expect($content)->toContain('operationId: renameSuperuserReplicationHost'); + expect($content)->toContain('enum: [database, redis, minio]'); + expect($content)->toContain('space_headroom_percent'); + expect($content)->toContain('SuperuserReplicationStatus'); + expect($content)->toContain('SuperuserReplicationHostCreateRequest'); + expect($content)->toContain('SuperuserReplicationHostRenameRequest'); + expect($content)->toContain('SuperuserReplicationComposeTemplateRequest'); +}); + +it('rejects subuser sessions before checking replication permissions', function (): void { + $content = file_get_contents(app_path('routes/superuserReplicationRoute.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('private function requireClassicSuperuserPermission(string $permission): bool'); + expect($content)->toContain('get_subuser() !== false'); + expect($content)->toContain("Subuser sessions cannot manage replication."); + expect($content)->toContain("\$response->error('Subuser sessions cannot manage replication.', 403);"); + expect($content)->toContain('return $this->requirePermission($permission);'); + expect(preg_match_all("/requireClassicSuperuserPermission\\('superuser_replication_/", $content))->toBe(11); + expect($content)->not->toContain("requirePermission('superuser_replication_"); +}); diff --git a/services/nginx/app/tests/Unit/Scanner/ModuleScannerRouteTest.php b/services/nginx/app/tests/Unit/Scanner/ModuleScannerRouteTest.php new file mode 100644 index 00000000..4fa6380e --- /dev/null +++ b/services/nginx/app/tests/Unit/Scanner/ModuleScannerRouteTest.php @@ -0,0 +1,10 @@ +not->toBeFalse(); + expect($route)->toContain("'reason' => 'no_license_plate_detected'"); + expect($route)->toContain('], 200);'); + expect($route)->not->toContain("throw new Exception('License plate extraction failed.')"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerHeartbeatStatusTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerHeartbeatStatusTest.php index 0ee352be..da5940f2 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerHeartbeatStatusTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerHeartbeatStatusTest.php @@ -108,7 +108,10 @@ it('merges incoming heartbeat metadata with existing gateway metadata', function $source = file_get_contents(app_path('classes/edge_gateway_manager.php')); expect($source)->toContain("\$existingMetadata = (array)(\$gateway->metadata_json->value() ?? []);"); - expect($source)->toContain("\$gateway->metadata_json->set(array_merge(\$existingMetadata, (array)(\$payload['metadata'] ?? [])));"); + expect($source)->toContain("\$payloadMetadata = (array)(\$payload['metadata'] ?? []);"); + expect($source)->toContain('$metadata = $this->mergeHeartbeatBrokerPresence($gatewayId, $existingMetadata, $payloadMetadata);'); + expect($source)->toContain('private function mergeHeartbeatBrokerPresence'); + expect($source)->toContain('$gateway->metadata_json->set($metadata);'); }); it('refreshes broker presence from broker telemetry heartbeats', function (): void { diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php index 582ba9e7..c62863d0 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php @@ -1,8 +1,10 @@ getVariableValue(); + $publicBrokerConfig->setVariableValue(''); + } catch (Throwable) { + $publicBrokerConfig = null; + } try { $callback(); @@ -27,6 +40,17 @@ function with_edge_gateway_server_state(array $server, callable $callback): void } else { putenv('EDGE_PUBLIC_API_URL=' . $originalPublicApiUrl); } + if ($originalPublicBrokerUrl === false) { + putenv('EDGE_PUBLIC_BROKER_URL'); + } else { + putenv('EDGE_PUBLIC_BROKER_URL=' . $originalPublicBrokerUrl); + } + if ($publicBrokerConfig !== null && $originalPublicBrokerConfig !== null) { + try { + $publicBrokerConfig->setVariableValue($originalPublicBrokerConfig); + } catch (Throwable) { + } + } } } diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayModuleRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayModuleRouteWiringTest.php index 2db2765b..8cf9e925 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayModuleRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayModuleRouteWiringTest.php @@ -27,10 +27,23 @@ it('registers edge gateway config endpoints from the module route directory', fu expect($route)->not->toBeFalse(); expect($route)->toContain("'/edgegateway/config'"); + expect($route)->toContain("'/edgegateway/config/broker-diagnostics'"); expect($route)->toContain('new edgegateway()'); + expect($route)->toContain('new edge_gateway_manager()'); expect($legacyRoute)->not->toContain("'/edgegateway/config'"); }); +it('registers broker settings as editable edge gateway module config', function (): void { + $module = file_get_contents(app_path('modules/edgegateway/edgegateway_c.php')); + + expect($module)->not->toBeFalse(); + expect($module) + ->toContain('edgegateway_broker_url_c::class') + ->toContain('edgegateway_public_broker_url_c::class') + ->toContain('edgegateway_broker_auth_mode_c::class') + ->not->toContain('edgegateway_broker_shared_secret_c::class,'); +}); + it('keeps only the module facade in the global classes directory and conditionally loads module routes', function (): void { $classes = glob(app_path('classes/*.php')) ?: []; $edgeGatewayClasses = array_values(array_filter($classes, static function (string $path): bool { diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php index e07c507e..6330fc22 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php @@ -28,6 +28,8 @@ it('registers the v2 operator-facing edge gateway routes', function (): void { it('registers PHP edge agent routes for operations and legacy relay command polling', function (): void { $route = file_get_contents(app_path('routes/edgeGatewaysRoute.php')); + $manager = file_get_contents(app_path('classes/edge_gateway_manager.php')); + $agent = file_get_contents(app_path('resources/edge-gateway-agent/agent.php')); expect($route)->toContain("'/edge-agent/install-token/verify'"); expect($route)->toContain("'/edge-agent/install-token/status'"); @@ -57,6 +59,9 @@ it('registers PHP edge agent routes for operations and legacy relay command poll expect($route)->toContain("'/edge-agent/internal/browser-streams/validate'"); expect($route)->toContain("'/edge-agent/internal/shell-sessions/opened'"); expect($route)->toContain('echo $exception->getMessage()'); + expect($manager)->toContain("\$gatewayPayload['broker_url'] = \$this->buildBrokerPublicUrl();"); + expect($agent)->toContain('applyBrokerUrlFromControlPlaneResponse'); + expect($agent)->toContain("Updated broker URL from control plane heartbeat response."); expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'"); expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'"); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php index 10edcd06..8ea270b9 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php @@ -68,10 +68,11 @@ it('builds the installer around the compose stack artifacts and management polli expect($launcherSource)->toContain('write_rollback_status "FAILED" "compose_up_failed" "$installed_version"'); expect($launcherSource)->toContain('write_rollback_status "FAILED" "rollback_apply_failed" "$installed_version"'); expect($launcherSource)->toContain('write_rollback_status "ROLLED_BACK" "healthcheck_failed" "$installed_version"'); - expect($composeSource)->toContain('version: "2.4"'); - expect($composeSource)->toContain('condition: service_healthy'); - expect($composeSource)->toContain("minio:\n condition: service_started"); - expect($composeSource)->toContain("mariadb:\n condition: service_started"); + $normalizedComposeSource = str_replace("\r\n", "\n", $composeSource); + expect($normalizedComposeSource)->toContain('version: "2.4"'); + expect($normalizedComposeSource)->toContain('condition: service_healthy'); + expect($normalizedComposeSource)->toContain("minio:\n condition: service_started"); + expect($normalizedComposeSource)->toContain("mariadb:\n condition: service_started"); expect($composeSource)->toContain('/opt/truckwash-edge-agent/runtime/control-plane-status.json'); expect($composeSource)->toContain('$$data[\\"last_loop_at\\"]'); expect($composeSource)->toContain('$$data[\\"last_successful_sync_at\\"]'); @@ -96,16 +97,21 @@ it('builds the installer around the compose stack artifacts and management polli expect($agentSource)->toContain("'last_transport_error'"); expect($agentSource)->toContain('private function recordTransportFailure(string $context, Throwable $throwable): void'); expect($edgeDockerfileSource)->toContain('FROM ${BASE_IMAGE}'); + expect($edgeDockerfileSource)->toContain('apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev;'); + expect($edgeDockerfileSource)->toContain('docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite;'); expect($edgeDockerfileSource)->toContain('extension_loaded($extension)'); expect($edgeDockerfileSource)->toContain('Missing PHP extension: {$extension}'); expect($edgeDockerfileSource)->toContain('COPY agent.php /opt/truckwash-edge-agent/agent.php'); expect($workerDockerfileSource)->toContain('FROM ${BASE_IMAGE}'); + expect($workerDockerfileSource)->toContain('apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev;'); + expect($workerDockerfileSource)->toContain('docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite;'); expect($workerDockerfileSource)->toContain('extension_loaded($extension)'); expect($workerDockerfileSource)->toContain('Missing PHP extension: {$extension}'); expect($workerDockerfileSource)->toContain('COPY lan-worker.php /opt/truckwash-edge-agent/lan-worker.php'); expect($autoUpdaterSource)->toContain("'/bin/bash ' . escapeshellarg(\$launcherPath) . ' reconcile 2>&1'"); expect($autoUpdaterDockerfileSource)->toContain('COPY auto-updater.php /usr/local/bin/auto-updater.php'); - expect($autoUpdaterDockerfileSource)->toContain('apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose;'); + expect($autoUpdaterDockerfileSource)->toContain('apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose libcurl4-openssl-dev libsqlite3-dev;'); + expect($autoUpdaterDockerfileSource)->toContain('docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite;'); expect($autoUpdaterDockerfileSource)->toContain('extension_loaded($extension)'); expect($serviceSource)->not->toContain('node /opt/truckwash-edge-agent/agent.mjs'); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayViewCacheTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayViewCacheTest.php index 39026323..5d9b7981 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayViewCacheTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayViewCacheTest.php @@ -36,6 +36,7 @@ if (!class_exists('EdgeGatewayViewCacheRedisFake')) { beforeEach(function (): void { $this->oldTtl = getenv('EDGE_GATEWAY_VIEW_CACHE_TTL'); + putenv('EDGE_GATEWAY_VIEW_CACHE_TTL=15'); $this->redis = new EdgeGatewayViewCacheRedisFake(); edge_gateway_view_cache::setAdapterForTests($this->redis); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/GatewayShellyTransportTest.php b/services/nginx/app/tests/Unit/Selfserve/GatewayShellyTransportTest.php index ae997385..9e081124 100644 --- a/services/nginx/app/tests/Unit/Selfserve/GatewayShellyTransportTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/GatewayShellyTransportTest.php @@ -23,7 +23,7 @@ class GatewayShellyTransportManagerFake extends edge_gateway_manager { } - public function dispatchRelayStatus(int $departmentId, string $logicalRelayId): array + public function dispatchRelayStatus(int $departmentId, string $logicalRelayId, array $actionContext = []): array { if ($this->statusException instanceof Exception) { throw $this->statusException; @@ -51,16 +51,17 @@ class GatewayShellyTransportManagerFake extends edge_gateway_manager ]; } - public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on): array + public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on, array $actionContext = []): array { - return $this->dispatchRelaySwitchWithTimer($departmentId, $logicalRelayId, $on, null); + return $this->dispatchRelaySwitchWithTimer($departmentId, $logicalRelayId, $on, null, $actionContext); } public function dispatchRelaySwitchWithTimer( int $departmentId, string $logicalRelayId, bool $on, - ?int $toggleAfterSeconds + ?int $toggleAfterSeconds, + array $actionContext = [] ): array { if ($this->switchException instanceof Exception) { @@ -84,7 +85,7 @@ class GatewayShellyTransportManagerFake extends edge_gateway_manager ]; } - public function dispatchRelayStatusLocalOnly(int $departmentId, string $logicalRelayId): array + public function dispatchRelayStatusLocalOnly(int $departmentId, string $logicalRelayId, array $actionContext = []): array { $this->localOnlyStatusCalls[] = [ 'department_id' => $departmentId, @@ -98,16 +99,17 @@ class GatewayShellyTransportManagerFake extends edge_gateway_manager ]; } - public function dispatchRelaySwitchLocalOnly(int $departmentId, string $logicalRelayId, bool $on): array + public function dispatchRelaySwitchLocalOnly(int $departmentId, string $logicalRelayId, bool $on, array $actionContext = []): array { - return $this->dispatchRelaySwitchLocalOnlyWithTimer($departmentId, $logicalRelayId, $on, null); + return $this->dispatchRelaySwitchLocalOnlyWithTimer($departmentId, $logicalRelayId, $on, null, $actionContext); } public function dispatchRelaySwitchLocalOnlyWithTimer( int $departmentId, string $logicalRelayId, bool $on, - ?int $toggleAfterSeconds + ?int $toggleAfterSeconds, + array $actionContext = [] ): array { $call = [ diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php index 9b801c1f..22a75fd9 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveConditionEvaluatorTest.php @@ -78,3 +78,189 @@ it('evaluates typed task gates with strict semantics', function (): void { expect($evaluator->taskGateSatisfiedTyped(selfserve_task_gate_type::QUESTION->value, 8, [], [8 => true]))->toBeTrue(); expect($evaluator->taskGateSatisfiedTyped(selfserve_task_gate_type::QUESTION->value, 8, [8 => true], [8 => false]))->toBeFalse(); }); + +it('evaluates nested v2 ALL and ANY expression trees with trace output', function (): void { + $evaluator = new selfserve_condition_evaluator(); + + $conditions = [ + [ + 'id' => 10, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'], + [ + 'type' => 'group', + 'operator' => 'ANY', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'], + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 3, 'operator' => 'IS_FALSE_OR_NOT_SET'], + ], + ], + ], + ], + ], + [ + 'id' => 20, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 10, 'operator' => 'IS_TRUE'], + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 4, 'operator' => 'IS_SET'], + ], + ], + ], + ]; + + $evaluation = $evaluator->evaluateExpressionsWithTrace($conditions, [ + 1 => true, + 2 => false, + 4 => false, + ]); + + expect($evaluation['results'])->toBe([ + 10 => true, + 20 => true, + ]); + expect($evaluation['trace'][10]['expression']['children'][1]['operator'])->toBe('ANY'); + expect($evaluation['trace'][20]['expression']['children'][0]['subject_type'])->toBe('condition'); +}); + +it('evaluates v2 if, else if, and else condition branches in order', function (): void { + $evaluator = new selfserve_condition_evaluator(); + + $evaluation = $evaluator->evaluateExpressionsWithTrace([ + [ + 'id' => 10, + 'expression' => [ + 'type' => 'branch', + 'branches' => [ + [ + 'kind' => 'if', + 'when' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 1, + 'operator' => 'IS_TRUE', + ], + 'then' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 2, + 'operator' => 'IS_TRUE', + ], + ], + [ + 'kind' => 'else_if', + 'when' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 3, + 'operator' => 'IS_TRUE', + ], + 'then' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 4, + 'operator' => 'IS_FALSE', + ], + ], + [ + 'kind' => 'else', + 'else' => true, + 'then' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 5, + 'operator' => 'IS_TRUE', + ], + ], + ], + ], + ], + ], [ + 1 => false, + 3 => true, + 4 => false, + 5 => false, + ]); + + expect($evaluation['results'][10])->toBeTrue(); + expect($evaluation['trace'][10]['expression']['selected_index'])->toBe(1); + expect($evaluation['trace'][10]['expression']['branches'][1]['kind'])->toBe('else_if'); +}); + +it('evaluates v2 case expressions against question values', function (): void { + $evaluator = new selfserve_condition_evaluator(); + + $evaluation = $evaluator->evaluateExpressionsWithTrace([ + [ + 'id' => 10, + 'expression' => [ + 'type' => 'case', + 'subject_type' => 'question', + 'subject_id' => 1, + 'cases' => [ + [ + 'value' => true, + 'then' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 2, + 'operator' => 'IS_TRUE', + ], + ], + [ + 'value' => false, + 'then' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 3, + 'operator' => 'IS_FALSE', + ], + ], + ], + ], + ], + ], [ + 1 => false, + 3 => false, + ]); + + expect($evaluation['results'][10])->toBeTrue(); + expect($evaluation['trace'][10]['expression']['selected_index'])->toBe(1); + expect($evaluation['trace'][10]['expression']['actual_value'])->toBeFalse(); +}); + +it('returns false and traces v2 condition expression cycles', function (): void { + $evaluator = new selfserve_condition_evaluator(); + + $evaluation = $evaluator->evaluateExpressionsWithTrace([ + [ + 'id' => 10, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 20, 'operator' => 'IS_TRUE'], + ], + ], + ], + [ + 'id' => 20, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 10, 'operator' => 'IS_TRUE'], + ], + ], + ], + ], []); + + expect($evaluation['results'][10])->toBeFalse(); + expect($evaluation['results'][20])->toBeFalse(); + expect(json_encode($evaluation['trace']))->toContain('Condition dependency cycle detected'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php index 7740b01b..450290e7 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveConfigVersioningTest.php @@ -82,6 +82,243 @@ it('fails validation when typed task gates reference unknown entities', function expect(implode("\n", $validation['errors']))->toContain('requires gate_ref_id'); }); +it('fails validation when nested conditions form cycles', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $validation = $service->validateConfig([ + 'questions' => [], + 'conditions' => [ + ['id' => 10, 'condition_id' => 12], + ['id' => 11, 'condition_id' => 10], + ['id' => 12, 'condition_id' => 11], + ], + 'rules' => [], + 'tasks' => [], + ]); + + expect($validation['valid'])->toBeFalse(); + expect(implode("\n", $validation['errors']))->toContain('Condition cycle detected'); +}); + +it('migrates legacy AND and OR rules into grouped v2 condition expressions', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $config = $service->migrateLegacyConfigToV2([ + 'department_id' => 6, + 'questions' => [ + ['id' => 1], + ['id' => 2], + ['id' => 3], + ], + 'conditions' => [ + ['id' => 10, 'name' => 'Ready'], + ], + 'rules' => [ + ['id' => 100, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1], + ['id' => 101, 'condition_id' => 10, 'type' => 'IS_TRUE_OR_ANY_TRUE', 'object_type' => 'question', 'object_id' => 2], + ['id' => 102, 'condition_id' => 10, 'type' => 'IS_TRUE_OR_ANY_TRUE', 'object_type' => 'question', 'object_id' => 3], + ], + 'tasks' => [], + ]); + + $expression = $config['conditions'][0]['expression']; + + expect($config['schema_version'])->toBe(2); + expect($config['rules'])->toBe([]); + expect($expression['operator'])->toBe('ALL'); + expect($expression['children'][0])->toMatchArray([ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 1, + 'operator' => 'IS_TRUE', + ]); + expect($expression['children'][1]['operator'])->toBe('ANY'); + expect(array_column($expression['children'][1]['children'], 'subject_id'))->toBe([2, 3]); +}); + +it('repairs legacy-defaulted always task gates during v2 normalization', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $config = $service->migrateLegacyConfigToV2([ + 'department_id' => 6, + 'questions' => [ + ['id' => 1], + ], + 'conditions' => [ + ['id' => 20, 'name' => 'Machine wash allowed'], + ], + 'rules' => [ + ['id' => 100, 'condition_id' => 20, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1], + ], + 'tasks' => [ + [ + 'id' => 200, + 'condition_id' => 20, + 'gate_type' => 'ALWAYS', + 'gate_ref_id' => null, + ], + ], + ]); + + expect($config['tasks'][0]['gate_type'])->toBe('CONDITION'); + expect($config['tasks'][0]['gate_ref_id'])->toBe(20); + expect($service->validateConfig($config)['valid'])->toBeTrue(); +}); + +it('does not let legacy-defaulted always task gates bypass validation', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $validation = $service->validateConfig([ + 'schema_version' => 2, + 'questions' => [ + ['id' => 1], + ], + 'conditions' => [ + [ + 'id' => 20, + 'expression' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 1, + 'operator' => 'IS_TRUE', + ], + ], + ], + 'tasks' => [ + [ + 'id' => 201, + 'condition_id' => 999, + 'gate_type' => 'ALWAYS', + 'gate_ref_id' => null, + ], + ], + ]); + + expect($validation['valid'])->toBeFalse(); + expect(implode("\n", $validation['errors']))->toContain('unknown question gate_ref_id 999'); +}); + +it('rejects unsupported legacy task-target rules after migration', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $config = $service->migrateLegacyConfigToV2([ + 'department_id' => 6, + 'questions' => [['id' => 1]], + 'conditions' => [['id' => 10, 'name' => 'Ready']], + 'rules' => [ + ['id' => 100, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'task', 'object_id' => 50], + ], + 'tasks' => [['id' => 50]], + ]); + $validation = $service->validateConfig($config); + + expect($validation['valid'])->toBeFalse(); + expect(implode("\n", $validation['errors']))->toContain('unsupported object_type `task`'); +}); + +it('validates v2 expressions for empty used conditions, missing refs, invalid operators, and cycles', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $validation = $service->validateConfig([ + 'schema_version' => 2, + 'questions' => [ + ['id' => 1, 'condition_id' => 10], + ], + 'conditions' => [ + ['id' => 10, 'expression' => ['type' => 'group', 'operator' => 'ALL', 'children' => []]], + [ + 'id' => 20, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 999, 'operator' => 'IS_TRUE'], + ['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 30, 'operator' => 'IS_TRUE'], + ], + ], + ], + [ + 'id' => 30, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'condition', 'subject_id' => 20, 'operator' => 'NOPE'], + ], + ], + ], + ], + 'tasks' => [ + ['id' => 100, 'gate_type' => 'CONDITION', 'gate_ref_id' => 10], + ], + ]); + + $errors = implode("\n", $validation['errors']); + + expect($validation['valid'])->toBeFalse(); + expect($errors)->toContain('Condition 10 is used but has an empty expression.'); + expect($errors)->toContain('unknown question predicate subject_id 999'); + expect($errors)->toContain('invalid predicate operator `NOPE`'); + expect($errors)->toContain('Condition cycle detected'); +}); + +it('validates nested v2 branch and case expressions', function (): void { + $service = selfserve_config_versioning_without_constructor(); + + $validation = $service->validateConfig([ + 'schema_version' => 2, + 'questions' => [ + ['id' => 1], + ['id' => 2], + ['id' => 3], + ], + 'conditions' => [ + [ + 'id' => 10, + 'expression' => [ + 'type' => 'branch', + 'branches' => [ + [ + 'kind' => 'if', + 'when' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'], + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'], + ], + [ + 'kind' => 'else', + 'else' => true, + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 3, 'operator' => 'IS_FALSE_OR_NOT_SET'], + ], + ], + ], + ], + [ + 'id' => 20, + 'expression' => [ + 'type' => 'case', + 'subject_type' => 'condition', + 'subject_id' => 10, + 'cases' => [ + [ + 'value' => true, + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'], + ], + [ + 'value' => false, + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 3, 'operator' => 'IS_FALSE'], + ], + ], + ], + ], + ], + 'tasks' => [ + ['id' => 100, 'gate_type' => 'CONDITION', 'gate_ref_id' => 20], + ], + ]); + + expect($validation['valid'])->toBeTrue(); + expect($validation['errors'])->toBe([]); +}); + it('encodes config json payloads with apostrophes before persistence', function (): void { $version = new class extends selfserve_config_versions_o { /** @var array */ diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php new file mode 100644 index 00000000..3c460973 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php @@ -0,0 +1,159 @@ + */ + public array $permissions = [ + 'list_own_department_selfserve_vehicle_conditions' => true, + ]; + public bool $laneEnabled = true; + public bool $activeWashResult = false; + /** @var array */ + public array $activeWashChecks = []; + + public function __construct() + { + // Avoid route_t request initialization in this focused unit test. + } + + public function hasPermission(string|\classes\permission_node $permission, int $customer_number = null): bool + { + $key = $permission instanceof \classes\permission_node ? (string)$permission->permission : $permission; + return $this->permissions[$key] ?? false; + } + + public function canUseLane(selfserve_lane $lane, int $customer_number): bool + { + return $this->canCustomerUseSelfServeLane($lane, $customer_number); + } + + public function canUseActiveLane(selfserve_lane $lane, int $customer_number): bool + { + return $this->canCustomerUseActiveSelfServeLane($lane, $customer_number); + } + + protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool + { + return $this->laneEnabled; + } + + protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool + { + $this->activeWashChecks[] = [ + 'department_id' => $department_id, + 'customer_number' => $customer_number, + ]; + + return $this->activeWashResult; + } +} + +class SelfserveCustomerLaneAccessDepartmentLaneFake extends department_lanes_o +{ + public function __construct(int $department_id) + { + $this->id = -1; + $this->department = new object_property('department_lanes', -1, 'department', 'int'); + $this->department->set($department_id); + } + + public function structure(): void + { + // Skip database bootstrap for this unit test. + } +} + +class SelfserveCustomerLaneAccessLaneFake extends selfserve_lane +{ + public ?int $fakeCustomerNumber = null; + + public function __construct(int $department_id, ?int $customer_number = null) + { + $this->id = 91; + $this->fakeCustomerNumber = $customer_number; + $this->department_lane = new SelfserveCustomerLaneAccessDepartmentLaneFake($department_id); + } + + public function getCustomerNumber(): ?int + { + return $this->fakeCustomerNumber; + } +} + +it('allows customers with own self-serve permission to use enabled self-serve lanes', function (): void { + $route = new SelfserveCustomerLaneAccessRouteHarness(); + $lane = new SelfserveCustomerLaneAccessLaneFake(4); + + expect($route->canUseLane($lane, 12345679))->toBeTrue(); +}); + +it('blocks customer lane mutations without own self-serve permission', function (): void { + $route = new SelfserveCustomerLaneAccessRouteHarness(); + $route->permissions = []; + $lane = new SelfserveCustomerLaneAccessLaneFake(4); + + expect($route->canUseLane($lane, 12345679))->toBeFalse(); +}); + +it('blocks customer lane mutations when the lane is not operationally enabled', function (): void { + $route = new SelfserveCustomerLaneAccessRouteHarness(); + $route->laneEnabled = false; + $lane = new SelfserveCustomerLaneAccessLaneFake(4); + + expect($route->canUseLane($lane, 12345679))->toBeFalse(); +}); + +it('allows active wash operations when the lane runtime belongs to the customer', function (): void { + $route = new SelfserveCustomerLaneAccessRouteHarness(); + $lane = new SelfserveCustomerLaneAccessLaneFake(4, 12345679); + + expect($route->canUseActiveLane($lane, 12345679))->toBeTrue(); + expect($route->activeWashChecks)->toBe([]); +}); + +it('keeps active wash operations available if a lane is disabled after start', function (): void { + $route = new SelfserveCustomerLaneAccessRouteHarness(); + $route->laneEnabled = false; + $lane = new SelfserveCustomerLaneAccessLaneFake(4, 12345679); + + expect($route->canUseActiveLane($lane, 12345679))->toBeTrue(); + expect($route->activeWashChecks)->toBe([]); +}); + +it('falls back to active department sessions for customer active wash operations', function (): void { + $route = new SelfserveCustomerLaneAccessRouteHarness(); + $route->activeWashResult = true; + $lane = new SelfserveCustomerLaneAccessLaneFake(4, null); + + expect($route->canUseActiveLane($lane, 12345679))->toBeTrue(); + expect($route->activeWashChecks)->toBe([ + [ + 'department_id' => 4, + 'customer_number' => 12345679, + ], + ]); +}); + +it('blocks active wash operations for other customers', function (): void { + $route = new SelfserveCustomerLaneAccessRouteHarness(); + $route->activeWashResult = false; + $lane = new SelfserveCustomerLaneAccessLaneFake(4, 99999999); + + expect($route->canUseActiveLane($lane, 12345679))->toBeFalse(); + expect($route->activeWashChecks)->toBe([ + [ + 'department_id' => 4, + 'customer_number' => 12345679, + ], + ]); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveInProgressWashAccessTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveInProgressWashAccessTest.php new file mode 100644 index 00000000..6f150e7b --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveInProgressWashAccessTest.php @@ -0,0 +1,76 @@ +scopeInProgressWashResponseForCustomer($payload, $customer_number); + } + } +} + +it('keeps own in-progress self-serve wash details visible to the customer', function (): void { + $route = new SelfserveInProgressWashAccessHarness(); + + $payload = [ + 'lane_id' => 7, + 'in_progress' => true, + 'session' => [ + 'id' => 704, + 'customer_number' => 12345679, + 'reg' => 'AB12345', + ], + 'customer' => [ + 'customer_number' => 12345679, + 'display_name' => 'Example Customer', + ], + 'vehicle' => [ + 'id' => 55, + 'reg' => 'AB12345', + ], + ]; + + expect($route->scopeForCustomer($payload, 12345679))->toBe($payload); +}); + +it('redacts another customers in-progress wash details during customer lane polling', function (): void { + $route = new SelfserveInProgressWashAccessHarness(); + + $scoped = $route->scopeForCustomer([ + 'lane_id' => 9, + 'status' => 'MACHINE_STARTED', + 'in_progress' => true, + 'elapsed_minutes' => 4, + 'session' => [ + 'id' => 804, + 'customer_number' => 99999999, + 'reg' => 'CD67890', + ], + 'customer' => [ + 'customer_number' => 99999999, + 'display_name' => 'Other Customer', + 'email' => 'other@example.test', + ], + 'vehicle' => [ + 'id' => 77, + 'reg' => 'CD67890', + ], + ], 12345679); + + expect($scoped)->toBe([ + 'lane_id' => 9, + 'in_progress' => true, + 'session' => null, + 'customer' => null, + 'vehicle' => null, + ]); +}); + diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneInvoiceModeBillingTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneInvoiceModeBillingTest.php index 6a73a3cb..d7018740 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneInvoiceModeBillingTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneInvoiceModeBillingTest.php @@ -12,6 +12,9 @@ use objects\orders_o; class SelfserveLaneInvoiceModeOrderStub extends orders_o { + public ?int $attachedBillingCustomerNumber = null; + public ?int $attachedDraftCustomerNumber = null; + public function __construct() {} } @@ -24,6 +27,10 @@ class SelfserveLaneInvoiceModeBillingHarness public ?int $lastAddedOrderId = null; public ?int $lastAddedProductId = null; public ?int $lastAddedQuantity = null; + public int $createdOrderContexts = 0; + public ?int $lastOrderCustomerNumber = null; + public ?int $lastAttachmentBillingCustomerNumber = null; + public ?int $lastAttachmentDraftCustomerNumber = null; private selfserve_lane_status $laneStatus = selfserve_lane_status::OCCUPIED; private selfserve_lane_mode $laneMode = selfserve_lane_mode::MANUAL; @@ -32,6 +39,7 @@ class SelfserveLaneInvoiceModeBillingHarness private int $elapsedWashTimeSeconds = 0; private int $minuteBillingProductId = 999; private int $includedMinutes = 0; + private ?int $draftCustomerNumber = null; public function setLaneModeForTest(selfserve_lane_mode $mode): void { @@ -48,6 +56,11 @@ class SelfserveLaneInvoiceModeBillingHarness $this->includedMinutes = $minutes; } + public function setDraftCustomerNumberForTest(?int $customer_number): void + { + $this->draftCustomerNumber = $customer_number; + } + public function getLaneStatus(): selfserve_lane_status { return $this->laneStatus; @@ -85,12 +98,29 @@ class SelfserveLaneInvoiceModeBillingHarness protected function createInvoiceOrderContext(): orders_o { + $this->createdOrderContexts++; + $billing_customer_number = $this->getCustomerNumber(); + $draft_customer_number = $this->draftCustomerNumber; $order = new SelfserveLaneInvoiceModeOrderStub(); $order->id = 424242; + $this->lastOrderCustomerNumber = $billing_customer_number; $this->last_invoice_order_id = (int)$order->id; + $this->attachSelfServeMetadataToOrder($order, $billing_customer_number, $draft_customer_number); return $order; } + protected function attachSelfServeMetadataToOrder( + orders_o $order, + int $billing_customer_number, + ?int $draft_customer_number, + ?\modules\selfserve\classes\selfserve_lane_command_arguments $arguments = null + ): void { + $order->attachedBillingCustomerNumber = $billing_customer_number; + $order->attachedDraftCustomerNumber = $draft_customer_number; + $this->lastAttachmentBillingCustomerNumber = $billing_customer_number; + $this->lastAttachmentDraftCustomerNumber = $draft_customer_number; + } + protected function addMinuteBillingLine(int $order_id, int $product_id, int $quantity): void { $this->lastAddedOrderId = $order_id; @@ -111,6 +141,7 @@ it('bills manual self-serve stop using full elapsed minutes without included-min expect($harness->lastAddedOrderId)->toBe(424242); expect($harness->lastAddedProductId)->toBe(999); expect($harness->lastAddedQuantity)->toBe(1); + expect($harness->createdOrderContexts)->toBe(1); expect($harness->getLastInvoiceOrderId())->toBe(424242); }); @@ -126,6 +157,30 @@ it('keeps included-minute reduction for automatic mode', function (): void { expect($harness->lastAddedOrderId)->toBeNull(); expect($harness->lastAddedProductId)->toBeNull(); expect($harness->lastAddedQuantity)->toBeNull(); + expect($harness->createdOrderContexts)->toBe(0); expect($harness->getLastInvoiceOrderId())->toBeNull(); }); +it('creates self-serve invoice orders for the actual lane customer when a draft customer is configured', function (): void { + $harness = new SelfserveLaneInvoiceModeBillingHarness(); + $harness->setLaneModeForTest(selfserve_lane_mode::MANUAL); + $harness->setElapsedWashTimeSecondsForTest(59); + $harness->setDraftCustomerNumberForTest(9999); + + $result = $harness->invoice(); + + expect($result)->toBeTrue(); + expect($harness->createdOrderContexts)->toBe(1); + expect($harness->lastOrderCustomerNumber)->toBe(1234); + expect($harness->lastAttachmentBillingCustomerNumber)->toBe(1234); + expect($harness->lastAttachmentDraftCustomerNumber)->toBe(9999); +}); + +it('keeps the real billing customer as the authoritative order customer in the invoice context', function (): void { + $source = file_get_contents(app_path('modules/selfserve/traits/selfserve_lane_invoice_t.php')); + + expect($source)->not->toContain('$order_customer_number = $draft_customer_number ?? $billing_customer_number'); + expect($source)->toContain('(new orders_o())->add( + $billing_customer_number,'); + expect($source)->toContain('$this->attachSelfServeMetadataToOrder($order, $billing_customer_number, $draft_customer_number, $arguments);'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php index 4db13f70..0ee52551 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php @@ -560,6 +560,20 @@ it('does not enable MACHINE relay when allowEnable is false even if MACHINE is v expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0); }); +it('persists allowed services without relay writes for pre-start wash setup', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + + $result = $harness->setAllowedServicesFromVisibleTasks(['machine']); + + expect($result)->toMatchArray([ + 'machine_visible' => true, + 'relay_action' => 'cache_only', + 'relay_target_on' => true, + ]); + expect($harness->getLaneCache($harness->id, $harness::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES))->toBe(['MACHINE']); + expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0); +}); + it('returns disabled no-op from machine relay visibility sync when department self-serve is disabled', function (): void { $harness = selfserve_lane_shelly_test_harness(); $harness->selfServeEnabled = false; diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStartEntranceTimeoutTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStartEntranceTimeoutTest.php new file mode 100644 index 00000000..178a5795 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStartEntranceTimeoutTest.php @@ -0,0 +1,117 @@ + */ + public array $openCalls = []; + public ?\Throwable $openThrowable = null; + public ?\Throwable $reportedTimeout = null; + public ?selfserve_lane_state $laneState = null; + public int $cleanerRelayCalls = 0; + public int $machineRelayCalls = 0; + + public function open(selfserve_lane_port $port, ?int $toggle_after_seconds = null): bool + { + unset($toggle_after_seconds); + $this->openCalls[] = $port; + if ($this->openThrowable !== null) { + throw $this->openThrowable; + } + + return true; + } + + public function setLaneState(selfserve_lane_state $state): void + { + $this->laneState = $state; + } + + protected function reportWashStartEntranceTimeout(\Throwable $e): void + { + $this->reportedTimeout = $e; + } + + protected function turnOnCleanerRelayForWashStart(): void + { + $this->cleanerRelayCalls++; + } + + protected function setMachineRelayStatusForWashStart(): void + { + $this->machineRelayCalls++; + } + + public function runEntranceOpenForStart(): void + { + $this->openEntrancePortForWashStart(); + } + + public function runStartRelaySideEffects(bool $defer): void + { + $arguments = (new selfserve_lane_command_arguments())->setDeferRelaySideEffects($defer); + $this->runRelaySideEffectsForWashStart($arguments); + } +} + +it('continues start when entrance relay dispatch times out ambiguously', function (): void { + $lane = new SelfserveLaneStartEntranceTimeoutHarness(); + $timeout = new \RuntimeException('Edge gateway command timed out'); + $lane->openThrowable = $timeout; + + $lane->runEntranceOpenForStart(); + + expect($lane->openCalls)->toBe([selfserve_lane_port::ENTRANCE]); + expect($lane->reportedTimeout)->toBe($timeout); +}); + +it('still fails start for non-timeout entrance relay errors', function (): void { + $lane = new SelfserveLaneStartEntranceTimeoutHarness(); + $lane->openThrowable = new \RuntimeException('Invalid relay ID for port ENTRANCE'); + + expect(fn() => $lane->runEntranceOpenForStart()) + ->toThrow(\RuntimeException::class, 'Invalid relay ID'); + expect($lane->reportedTimeout)->toBeNull(); +}); + +it('parses deferred relay side effects on start command arguments', function (): void { + $arguments = (new selfserve_lane_command_arguments())->setParameters([ + 'license_plate' => 'ab12345', + 'customer_number' => 12345679, + 'defer_relay_side_effects' => true, + ]); + + expect($arguments->license_plate)->toBe('AB12345'); + expect($arguments->customer_number)->toBe(12345679); + expect($arguments->defer_relay_side_effects)->toBeTrue(); +}); + +it('skips cleaner and machine relay side effects when start asks to defer them', function (): void { + $lane = new SelfserveLaneStartEntranceTimeoutHarness(); + + $lane->runStartRelaySideEffects(true); + + expect($lane->cleanerRelayCalls)->toBe(0); + expect($lane->machineRelayCalls)->toBe(0); +}); + +it('keeps cleaner and machine relay side effects for normal start commands', function (): void { + $lane = new SelfserveLaneStartEntranceTimeoutHarness(); + + $lane->runStartRelaySideEffects(false); + + expect($lane->cleanerRelayCalls)->toBe(1); + expect($lane->machineRelayCalls)->toBe(1); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php index 02bc9440..f0993c7b 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php @@ -55,6 +55,10 @@ class SelfserveLaneStopFlowHarness public int $invoiceCalls = 0; public int $ensureInvoiceOrderContextCalls = 0; public int $vehicleTypeProductAddCalls = 0; + public int $programSelectorStatusReads = 0; + public ?bool $lastVehicleTypeProductDecision = null; + public ?\Throwable $openThrowable = null; + public ?\Throwable $reportedStopTimeout = null; /** @var selfserve_lane_port[] */ public array $openedPorts = []; /** @var selfserve_lane_relay[] */ @@ -70,14 +74,17 @@ class SelfserveLaneStopFlowHarness private bool $bypassCustomerValidation = false; private bool $programSelectorOnline; private bool $programSelectorOn; + private bool $machineStartTriggered; public function __construct( - bool $programSelectorOnline, + bool $machineStartTriggered, + bool $programSelectorOnline = false, bool $programSelectorOn = true, string $machineRelayId = 'relay-machine', string $programRelayId = 'relay-program', string $cleanerRelayId = 'relay-cleaner' ) { + $this->machineStartTriggered = $machineStartTriggered; $this->programSelectorOnline = $programSelectorOnline; $this->programSelectorOn = $programSelectorOn; $this->department_lane = new SelfserveLaneCommandDepartmentLaneFake( @@ -165,6 +172,7 @@ class SelfserveLaneStopFlowHarness public function getMachineProgramPickerRelayStatus(): array { + $this->programSelectorStatusReads++; return [ 'online' => $this->programSelectorOnline, 'on' => $this->programSelectorOn, @@ -174,6 +182,10 @@ class SelfserveLaneStopFlowHarness public function open(selfserve_lane_port $port): bool { $this->openedPorts[] = $port; + if ($this->openThrowable !== null) { + throw $this->openThrowable; + } + return true; } @@ -197,17 +209,37 @@ class SelfserveLaneStopFlowHarness { // No-op in unit tests. } + + protected function hasMachineStartSignalForStop(): bool + { + return $this->machineStartTriggered; + } + + protected function addVehicleTypeProductToInvoiceIfNeeded(bool $should_add): void + { + $this->lastVehicleTypeProductDecision = $should_add; + if ($should_add) { + $this->vehicleTypeProductAddCalls++; + } + } + + protected function reportWashStopExitTimeout(\Throwable $e): void + { + $this->reportedStopTimeout = $e; + } } -it('adds vehicle type product on STOP when program selector relay is online, then turns off cleaner and machine relays', function (): void { - $lane = new SelfserveLaneStopFlowHarness(programSelectorOnline: true); +it('adds vehicle type product on STOP when the physical machine ON signal was recorded, then turns off cleaner and machine relays', function (): void { + $lane = new SelfserveLaneStopFlowHarness(machineStartTriggered: true); $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); $lane->execute(selfserve_lane_command::STOP, $args); expect($lane->invoiceCalls)->toBe(1); expect($lane->ensureInvoiceOrderContextCalls)->toBe(0); - expect($lane->vehicleTypeProductAddCalls)->toBe(0); + expect($lane->vehicleTypeProductAddCalls)->toBe(1); + expect($lane->lastVehicleTypeProductDecision)->toBeTrue(); + expect($lane->programSelectorStatusReads)->toBe(0); expect($lane->openedPorts)->toBe([selfserve_lane_port::EXIT]); expect($lane->turnedOffRelays)->toBe([ selfserve_lane_relay::MACHINE_CLEANER, @@ -216,8 +248,9 @@ it('adds vehicle type product on STOP when program selector relay is online, the expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE); }); -it('skips vehicle type product add when program selector relay is offline and only disables configured relays', function (): void { +it('skips vehicle type product add when no physical machine ON signal was recorded and only disables configured relays', function (): void { $lane = new SelfserveLaneStopFlowHarness( + machineStartTriggered: false, programSelectorOnline: false, machineRelayId: 'relay-machine', programRelayId: 'relay-program', @@ -230,15 +263,18 @@ it('skips vehicle type product add when program selector relay is offline and on expect($lane->invoiceCalls)->toBe(1); expect($lane->ensureInvoiceOrderContextCalls)->toBe(0); expect($lane->vehicleTypeProductAddCalls)->toBe(0); + expect($lane->lastVehicleTypeProductDecision)->toBeFalse(); + expect($lane->programSelectorStatusReads)->toBe(0); expect($lane->turnedOffRelays)->toBe([ selfserve_lane_relay::MACHINE, ]); }); -it('bills primary product when selector relay is online even if relay output is off', function (): void { +it('does not use selector relay online status as machine-wash billing evidence', function (): void { $lane = new SelfserveLaneStopFlowHarness( + machineStartTriggered: false, programSelectorOnline: true, - programSelectorOn: false + programSelectorOn: true ); $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); @@ -247,4 +283,37 @@ it('bills primary product when selector relay is online even if relay output is expect($lane->invoiceCalls)->toBe(1); expect($lane->ensureInvoiceOrderContextCalls)->toBe(0); expect($lane->vehicleTypeProductAddCalls)->toBe(0); + expect($lane->lastVehicleTypeProductDecision)->toBeFalse(); + expect($lane->programSelectorStatusReads)->toBe(0); +}); + +it('continues STOP when exit relay dispatch times out ambiguously', function (): void { + $lane = new SelfserveLaneStopFlowHarness(machineStartTriggered: true); + $timeout = new \RuntimeException('Edge gateway command timed out'); + $lane->openThrowable = $timeout; + $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); + + $lane->execute(selfserve_lane_command::STOP, $args); + + expect($lane->openedPorts)->toBe([selfserve_lane_port::EXIT]); + expect($lane->reportedStopTimeout)->toBe($timeout); + expect($lane->invoiceCalls)->toBe(1); + expect($lane->vehicleTypeProductAddCalls)->toBe(1); + expect($lane->turnedOffRelays)->toBe([ + selfserve_lane_relay::MACHINE_CLEANER, + selfserve_lane_relay::MACHINE, + ]); + expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE); +}); + +it('still fails STOP for non-timeout exit relay errors', function (): void { + $lane = new SelfserveLaneStopFlowHarness(machineStartTriggered: true); + $lane->openThrowable = new \RuntimeException('Invalid relay ID for port EXIT'); + $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); + + expect(fn() => $lane->execute(selfserve_lane_command::STOP, $args)) + ->toThrow(\RuntimeException::class, 'Invalid relay ID'); + expect($lane->reportedStopTimeout)->toBeNull(); + expect($lane->invoiceCalls)->toBe(0); + expect($lane->getLaneStatus())->toBe(selfserve_lane_status::OCCUPIED); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveMachineSignalTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveMachineSignalTest.php new file mode 100644 index 00000000..adaad99d --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveMachineSignalTest.php @@ -0,0 +1,51 @@ +normalizeShellyPayload([ + 'event' => 'input.toggle_on', + 'component' => 'input:0', + 'state' => true, + 'relay_id' => 'M-1', + ]); + + expect($signal['recognized'])->toBeTrue() + ->and($signal['on'])->toBeTrue() + ->and($signal['component'])->toBe('input') + ->and($signal['relay_id'])->toBe('M-1'); +}); + +it('normalizes Shelly switch ON events and nested status payloads', function (): void { + $service = new selfserve_machine_signal(); + + $switchSignal = $service->normalizeShellyPayload([ + 'event' => 'switch.on', + 'component' => 'switch:0', + 'output' => true, + ]); + $statusSignal = $service->normalizeShellyPayload([ + 'status' => [ + 'input:0' => ['state' => true], + ], + ]); + + expect($switchSignal['recognized'])->toBeTrue() + ->and($switchSignal['on'])->toBeTrue() + ->and($switchSignal['component'])->toBe('switch') + ->and($statusSignal['recognized'])->toBeTrue() + ->and($statusSignal['on'])->toBeTrue(); +}); + +it('recognizes Shelly OFF events but does not treat them as billable machine starts', function (): void { + $signal = (new selfserve_machine_signal())->normalizeShellyPayload([ + 'event' => 'input.toggle_off', + 'component' => 'input:0', + 'state' => false, + ]); + + expect($signal['recognized'])->toBeTrue() + ->and($signal['on'])->toBeFalse(); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveNonOwnedVehicleWashAccessTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveNonOwnedVehicleWashAccessTest.php new file mode 100644 index 00000000..d0a674ed --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveNonOwnedVehicleWashAccessTest.php @@ -0,0 +1,73 @@ +resolvePersistedAnswerCustomerNumber($resolvedCustomerNumber); + } +} + +function selfserve_non_owned_vehicle_route_block(string $route, string $method, string $path): string +{ + $start = strpos($route, "\$this->{$method}('{$path}'"); + if ($start === false) { + throw new RuntimeException("Route block not found: {$method} {$path}"); + } + + $nextComment = strpos($route, "\n /**", $start + 1); + if ($nextComment === false) { + return substr($route, $start); + } + + return substr($route, $start, $nextComment - $start); +} + +it('allows own-permission customers to preview borrowed registration plates without ownership checks', function (): void { + $route = file_get_contents(app_path('routes/departmentSelfserveVehicleConditionsRoute.php')); + expect($route)->not->toBeFalse(); + + $allowedBlock = selfserve_non_owned_vehicle_route_block($route, 'get', '/department/selfserve/vehicle/allowed'); + + expect($allowedBlock)->toContain("requireAuthenticatedCustomerNumber(\$user, 'list_department_selfserve_vehicle_conditions')"); + expect($allowedBlock)->toContain('previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)'); + expect($allowedBlock)->not->toContain('assertOwnVehicle'); +}); + +it('allows customer-scoped answers to be stored for borrowed registration plates', function (): void { + $route = file_get_contents(app_path('routes/departmentSelfserveVehicleConditionsRoute.php')); + expect($route)->not->toBeFalse(); + + $addBlock = selfserve_non_owned_vehicle_route_block($route, 'post', '/department/selfserve/vehicle/conditions'); + + expect($addBlock)->toContain("requireAuthenticatedCustomerNumber(\$user, 'add_department_selfserve_vehicle_conditions')"); + expect($addBlock)->toContain('$condition_o->add($department, $lane, $reg, $question, $value, $customer_id)'); + expect($addBlock)->not->toContain('assertOwnVehicle'); +}); + +it('loads saved answers from the authenticated customer context instead of the plate owner', function (): void { + $flow = new SelfserveNonOwnedVehicleWashFlowHarness(); + + expect($flow->persistedAnswerCustomer(10001))->toBe(10001); + expect($flow->persistedAnswerCustomer(20002))->toBe(20002); + expect($flow->persistedAnswerCustomer(null))->toBeNull(); + expect($flow->persistedAnswerCustomer(0))->toBeNull(); +}); + +it('scopes saved self-serve answers by customer number and registration plate', function (): void { + $source = file_get_contents(app_path('objects/department_selfserve_vehicle_conditions_o.php')); + + expect($source)->not->toBeFalse(); + expect($source)->toContain('department, lane, customer, reg and question'); + expect($source)->toContain('$customer_filter = $customer_id === null'); + expect($source)->toContain("'customer_id' => \$customerId"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php index 9732af21..4112dfd1 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php @@ -50,6 +50,7 @@ it('documents self-serve machine type, eligibility, summary, and webhook endpoin expect($content)->toContain('/department/selfserve/vehicle/allowed:'); expect($content)->toContain('/department/selfserve/washes/summary:'); expect($content)->toContain('/relay/button/press/post:'); + expect($content)->toContain('/relay/machine/on/post:'); expect($content)->toContain('/modules/self-serve/lane/wash/in-progress:'); expect($content)->toContain('/modules/self-serve/lane/relay/machine/status:'); expect($content)->toContain('/modules/self-serve/lane/relay/machine/set:'); @@ -60,10 +61,40 @@ it('documents self-serve machine type, eligibility, summary, and webhook endpoin expect($content)->toContain('/modules/self-serve/lane/relay/machine_cleaner/set:'); expect($allowedPathBlock)->toContain('name: vehicle_type_id'); expect($allowedPathBlock)->toContain('name: vehicle_type'); + expect($allowedPathBlock)->toContain('may evaluate any registration plate'); + expect($allowedPathBlock)->toContain('Persisted self-serve answers are only applied'); expect($summaryPathBlock)->toContain('name: vehicle_type_id'); expect($summaryPathBlock)->toContain('name: vehicle_type'); }); +it('documents the all-in-one self-serve studio replacement API', function (): void { + $content = selfserve_openapi_content_or_skip(); + + expect($content)->toContain('/department/selfserve/studio/graph:'); + expect($content)->toContain('/department/selfserve/studio/layout:'); + expect($content)->toContain('/department/selfserve/studio/validate:'); + expect($content)->toContain('/department/selfserve/studio/simulate:'); + expect($content)->toContain('/department/selfserve/studio/path-outcomes:'); + expect($content)->toContain('/department/selfserve/studio/path-outcomes/stream:'); + expect($content)->toContain('/department/selfserve/studio/path-confirmations:'); + expect($content)->toContain('/department/selfserve/studio/publish:'); + expect($content)->toContain('/department/selfserve/studio/rollback:'); + expect($content)->toContain('/department/selfserve/studio/gateway-action:'); + expect($content)->toContain('SelfserveStudioGraph:'); + expect($content)->toContain('SelfserveStudioGraphSaveRequest:'); + expect($content)->toContain('SelfserveStudioLayout:'); + expect($content)->toContain('SelfserveStudioPathOutcomesRequest:'); + expect($content)->toContain('SelfserveStudioPathOutcomesResponse:'); + expect($content)->toContain('SelfserveStudioPathResult:'); + expect($content)->toContain('SelfserveStudioPathProgress:'); + expect($content)->toContain('SelfserveStudioPathConfirmationRequest:'); + expect($content)->toContain('SelfserveStudioPathConfirmation:'); + expect($content)->toContain('projectSelfserveStudioPathOutcomes'); + expect($content)->toContain('streamSelfserveStudioPathOutcomes'); + expect($content)->toContain('confirmSelfserveStudioPath'); + expect($content)->toContain('runSelfserveStudioGatewayAction'); +}); + it('defines reusable self-serve wash and machine type schemas', function (): void { $content = selfserve_openapi_content_or_skip(); @@ -72,6 +103,9 @@ it('defines reusable self-serve wash and machine type schemas', function (): voi expect($content)->toContain('SelfserveWashSummary:'); expect($content)->toContain('MachineButtonPressWebhookResponse:'); expect($content)->toContain('DepartmentSelfserveVehicleConditionMutationResponse:'); + expect($content)->toContain('DepartmentLane:'); + expect($content)->toContain('selfserve_enabled:'); + expect($content)->toContain('blocked_reason:'); expect($content)->toContain('machine_type_id:'); expect($content)->toContain('SelfServeLaneMachineRelayStatus:'); expect($content)->toContain(' wash_started_at:'); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfservePropertyGatePermissionBypassTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfservePropertyGatePermissionBypassTest.php new file mode 100644 index 00000000..8af460a2 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfservePropertyGatePermissionBypassTest.php @@ -0,0 +1,121 @@ + */ + public array $permissions = [ + 'list_own_department_selfserve_vehicle_conditions' => true, + ]; + /** @var array */ + public array $activeWashChecks = []; + + public function hasPermission(string|\classes\permission_node $permission, int $customer_number = null): bool + { + $key = $permission instanceof \classes\permission_node ? (string)$permission->permission : $permission; + return $this->permissions[$key] ?? false; + } + + public function canUsePropertyGate(selfserve_lane $lane, int $customer_number): bool + { + return $this->canCustomerUsePropertyGateForLane($lane, $customer_number); + } + + protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool + { + return true; + } + + protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool + { + $this->activeWashChecks[] = [ + 'department_id' => $department_id, + 'customer_number' => $customer_number, + ]; + + return $this->activeWashResult; + } +} + +class SelfservePropertyGatePermissionBypassDepartmentLaneFake extends department_lanes_o +{ + public function __construct(int $department_id) + { + $this->id = -1; + $this->department = new object_property('department_lanes', -1, 'department', 'int'); + $this->department->set($department_id); + } + + public function structure(): void + { + // Skip database bootstrap for this unit test. + } +} + +function selfserve_property_gate_permission_lane_for_department(int $department_id): selfserve_lane +{ + $reflection = new ReflectionClass(selfserve_lane::class); + /** @var selfserve_lane $lane */ + $lane = $reflection->newInstanceWithoutConstructor(); + $lane->id = 77; + $lane->department_lane = new SelfservePropertyGatePermissionBypassDepartmentLaneFake($department_id); + + return $lane; +} + +it('allows property gate commands for customers with an active wash in the target department', function (): void { + $route = new SelfservePropertyGatePermissionBypassRouteHarness(); + $route->activeWashResult = true; + $lane = selfserve_property_gate_permission_lane_for_department(6); + + expect($route->canUsePropertyGate($lane, 12345679))->toBeTrue(); + expect($route->activeWashChecks)->toBe([ + [ + 'department_id' => 6, + 'customer_number' => 12345679, + ], + ]); +}); + +it('does not bypass property gate permissions without a positive customer number', function (): void { + $route = new SelfservePropertyGatePermissionBypassRouteHarness(); + $route->activeWashResult = true; + $lane = selfserve_property_gate_permission_lane_for_department(6); + + expect($route->canUsePropertyGate($lane, 0))->toBeFalse(); + expect($route->activeWashChecks)->toBe([]); +}); + +it('does not bypass property gate permissions without customer self-serve permission', function (): void { + $route = new SelfservePropertyGatePermissionBypassRouteHarness(); + $route->activeWashResult = true; + $route->permissions = []; + $lane = selfserve_property_gate_permission_lane_for_department(6); + + expect($route->canUsePropertyGate($lane, 12345679))->toBeFalse(); + expect($route->activeWashChecks)->toBe([]); +}); + +it('does not bypass property gate permissions when the customer has no active wash in the target department', function (): void { + $route = new SelfservePropertyGatePermissionBypassRouteHarness(); + $route->activeWashResult = false; + $lane = selfserve_property_gate_permission_lane_for_department(6); + + expect($route->canUsePropertyGate($lane, 12345679))->toBeFalse(); + expect($route->activeWashChecks)->toBe([ + [ + 'department_id' => 6, + 'customer_number' => 12345679, + ], + ]); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveRelayVisibilitySyncWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveRelayVisibilitySyncWiringTest.php index 09fa8d9d..9e3ca826 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveRelayVisibilitySyncWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveRelayVisibilitySyncWiringTest.php @@ -4,6 +4,8 @@ it('synchronizes machine relay from visible services during session synchronizat $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); expect($washFlow)->not->toBeFalse(); + expect($washFlow)->toContain('bool $syncRelayState = true'); + expect($washFlow)->toContain('if ($syncRelayState) {'); expect($washFlow)->toContain('$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);'); expect($washFlow)->toContain('protected function syncMachineRelayFromVisibleServices'); expect($washFlow)->toContain('$lane->syncMachineRelayFromVisibleServices('); @@ -19,4 +21,3 @@ it('adds explicit relay disable session helper for visibility-driven OFF transit expect($sessionsObject)->toContain('$this->machine_relay_enabled->set(false);'); expect($sessionsObject)->toContain('$this->machine_relay_enabled_at->set(null);'); }); - diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php index 2d8cd6c8..102416a4 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php @@ -15,7 +15,24 @@ it('wires self-serve machine types, eligibility, summaries, and machine-start we expect($webhookRoute)->not->toBeFalse(); expect($webhookRoute)->toContain('/relay/button/press/post'); + expect($webhookRoute)->toContain('/relay/machine/on/post'); expect($webhookRoute)->toContain('recordMachineStartWebhook'); + expect($webhookRoute)->toContain('recordCloudShellySignal'); +}); + +it('wires local edge gateway machine ON signal monitor endpoints', function (): void { + $edgeGatewayRoute = file_get_contents(app_path('modules/edgegateway/routes/edgeGatewaysRoute.php')); + $agent = file_get_contents(app_path('resources/edge-gateway-agent/agent.php')); + $worker = file_get_contents(app_path('resources/edge-gateway-agent/lan-worker.php')); + + expect($edgeGatewayRoute)->not->toBeFalse() + ->and($edgeGatewayRoute)->toContain('/edge-agent/gateways/{id}/selfserve/machine-signal-bindings') + ->and($edgeGatewayRoute)->toContain('/edge-agent/gateways/{id}/selfserve/machine-signal') + ->and($edgeGatewayRoute)->toContain('recordEdgeGatewaySignal') + ->and($agent)->toContain('pollMachineStartSignals') + ->and($agent)->toContain('/selfserve/machine-signal') + ->and($worker)->toContain('Input.GetStatus') + ->and($worker)->toContain('/relay/input-status'); }); it('keeps machine type support wired into lanes, tasks, and conditions routes', function (): void { @@ -32,6 +49,39 @@ it('keeps machine type support wired into lanes, tasks, and conditions routes', ->toContain('product'); }); +it('wires lane-level self-serve toggles through lane APIs, guest payloads, and edge workspace readiness', function (): void { + $lanesRoute = file_get_contents(app_path('routes/departmentLanesRoute.php')); + $laneObject = file_get_contents(app_path('objects/department_lanes_o.php')); + $guestRoute = file_get_contents(app_path('routes/guestRoute.php')); + $edgeWorkspace = file_get_contents(app_path('modules/edgegateway/classes/edge_gateway_department_workspace_service.php')); + + expect($lanesRoute)->not->toBeFalse() + ->and($lanesRoute)->toContain("'selfserve_enabled'") + ->and($lanesRoute)->toContain('normalizeSelfServeEnabledValue') + ->and($lanesRoute)->toContain('disableSelfServeRelaysBestEffort') + ->and($lanesRoute)->toContain("'lane' => \$created_lane->asArray()") + ->and($lanesRoute)->toContain("'lane' => \$department_lane->asArray()"); + + expect($laneObject)->not->toBeFalse() + ->and($laneObject)->toContain('public object_property $selfserve_enabled') + ->and($laneObject)->toContain('public function isSelfServeEnabled(): bool') + ->and($laneObject)->toContain('public static function normalizeSelfServeEnabledValue') + ->and($laneObject)->toContain('public static function disableSelfServeRelaysBestEffort') + ->and($laneObject)->toContain('setMachineRelayStatusHard(false)') + ->and($laneObject)->toContain('setMachineProgramPickerRelayStatusHard(false)') + ->and($laneObject)->toContain('setMachineCleanerRelayStatusHard(false)'); + + expect($guestRoute)->not->toBeFalse() + ->and($guestRoute)->toContain("'selfserve_enabled' => \$lane->isSelfServeEnabled()") + ->and($guestRoute)->toContain("'machine_available' => \$lane->isSelfServeEnabled() && !empty(\$lane->relay_machine_id->value())"); + + expect($edgeWorkspace)->not->toBeFalse() + ->and($edgeWorkspace)->toContain("'selfserve_enabled' => \$lane->isSelfServeEnabled()") + ->and($edgeWorkspace)->toContain("'enabled_lanes' => count(\$enabledLanes)") + ->and($edgeWorkspace)->toContain("'ready_lanes' => count(\$readyLanes)") + ->and($edgeWorkspace)->toContain("(int)(\$selfServe['ready_lanes'] ?? 0) < (int)(\$selfServe['enabled_lanes'] ?? 0)"); +}); + it('wires self-serve config draft/publish/rollback lifecycle endpoints', function (): void { $configRoute = file_get_contents(app_path('routes/departmentSelfserveConfigVersionsRoute.php')); @@ -47,6 +97,36 @@ it('wires self-serve config draft/publish/rollback lifecycle endpoints', functio expect($configRoute)->toContain('rollback_department_selfserve_config_versions'); }); +it('wires the all-in-one self-serve studio replacement endpoints', function (): void { + $studioRoute = file_get_contents(app_path('routes/departmentSelfserveStudioRoute.php')); + $studioGraph = file_get_contents(app_path('modules/selfserve/classes/selfserve_studio_graph.php')); + + expect($studioRoute)->not->toBeFalse(); + expect($studioRoute)->toContain('/department/selfserve/studio/graph'); + expect($studioRoute)->toContain('/department/selfserve/studio/layout'); + expect($studioRoute)->toContain('/department/selfserve/studio/validate'); + expect($studioRoute)->toContain('/department/selfserve/studio/simulate'); + expect($studioRoute)->toContain('/department/selfserve/studio/path-outcomes'); + expect($studioRoute)->toContain('/department/selfserve/studio/path-outcomes/stream'); + expect($studioRoute)->toContain('/department/selfserve/studio/path-confirmations'); + expect($studioRoute)->toContain("ini_set('display_errors', '0')"); + expect($studioRoute)->toContain('/department/selfserve/studio/publish'); + expect($studioRoute)->toContain('/department/selfserve/studio/rollback'); + expect($studioRoute)->toContain('/department/selfserve/studio/gateway-action'); + expect($studioRoute)->toContain('projectPathOutcomes'); + expect($studioRoute)->toContain('confirmPathOutcome'); + expect($studioRoute)->toContain('modules_shelly_config'); + expect($studioRoute)->toContain('modules_selfserve_sessions_force_stop'); + + expect($studioGraph)->not->toBeFalse(); + expect($studioGraph)->toContain('department_selfserve_studio_layouts'); + expect($studioGraph)->toContain('department_selfserve_path_confirmations'); + expect($studioGraph)->toContain('buildGatewayWorkspace'); + expect($studioGraph)->toContain('runGatewayAction'); + expect($studioGraph)->toContain('layout_affects_runtime'); + expect($studioGraph)->toContain('resolved'); +}); + it('wires machine wash included minutes into self-serve module config', function (): void { $selfserveConfig = file_get_contents(app_path('modules/selfserve/selfserve_c.php')); @@ -89,13 +169,39 @@ it('wires machine relay status get and set endpoints', function (): void { expect($moduleSelfServeRoute)->toContain('buildRelayStatusResponse'); }); +it('wires dashboard lane machine status and Dognvask toggle endpoints', function (): void { + $lanesRoute = file_get_contents(app_path('routes/departmentLanesRoute.php')); + $laneObject = file_get_contents(app_path('objects/department_lanes_o.php')); + $moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); + + expect($lanesRoute)->not->toBeFalse() + ->and($lanesRoute)->toContain('/department/lanes/status-toggles') + ->and($lanesRoute)->toContain('LIST_DEPARTMENT_LANE_STATUS_TOGGLES') + ->and($lanesRoute)->toContain('getDepartmentLanes($department_id)'); + + expect($laneObject)->not->toBeFalse() + ->and($laneObject)->toContain("'machine_status_enabled' => self::isOperationalStatusName(\$status)") + ->and($laneObject)->toContain("'dognvask_configured' => \$selfserve_configuration_warnings === []") + ->and($laneObject)->toContain("'dognvask_configuration_warnings' => \$selfserve_configuration_warnings") + ->and($laneObject)->toContain('public function getSelfServeConfigurationWarnings(): array') + ->and($laneObject)->toContain('relay_machine_program_picker_id') + ->and($laneObject)->toContain('machine_type_id'); + + expect($moduleSelfServeRoute)->not->toBeFalse() + ->and($moduleSelfServeRoute)->toContain("\$this->put('/modules/self-serve/lane/status'") + ->and($moduleSelfServeRoute)->toContain('modules_selfserve_lane_status_set') + ->and($moduleSelfServeRoute)->toContain('setLaneStatus($target_status)') + ->and($moduleSelfServeRoute)->toContain('selfserve_lane_status::MAINTENANCE') + ->and($moduleSelfServeRoute)->toContain('machine_status_enabled'); +}); + it('applies Shelly transport overrides across self-serve relay side-effect routes', function (): void { $moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); expect($moduleSelfServeRoute)->not->toBeFalse(); expect(substr_count($moduleSelfServeRoute, 'applyShellyTransportOverride($lane)'))->toBeGreaterThanOrEqual(17); expect($moduleSelfServeRoute)->toContain('$lane->execute($command, $args);'); - expect($moduleSelfServeRoute)->toContain('$lane->syncMachineRelayFromVisibleServices($allowed_services, true);'); + expect($moduleSelfServeRoute)->toContain('$lane->setAllowedServicesFromVisibleTasks($allowed_services);'); expect($moduleSelfServeRoute)->toContain('$lane->open($gate, $toggle_after);'); expect($moduleSelfServeRoute)->toContain('$lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration);'); expect($moduleSelfServeRoute)->toContain('$lane->turnOnRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $duration);'); @@ -113,7 +219,8 @@ it('wires allowed services route through machine relay visibility sync', functio expect($moduleSelfServeRoute)->not->toBeFalse(); expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/services/allowed'); - expect($moduleSelfServeRoute)->toContain('syncMachineRelayFromVisibleServices($allowed_services, true)'); + expect($moduleSelfServeRoute)->toContain('setAllowedServicesFromVisibleTasks($allowed_services)'); + expect($moduleSelfServeRoute)->not->toContain('syncMachineRelayFromVisibleServices($allowed_services, true)'); expect($moduleSelfServeRoute)->toContain("'relay_sync' => \$relay_sync"); }); @@ -157,6 +264,9 @@ it('wires in-progress self-serve wash details endpoint', function (): void { expect($moduleSelfServeRoute)->not->toBeFalse(); expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/wash/in-progress'); expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_wash_in_progress_view'); + expect($moduleSelfServeRoute)->toContain('list_own_department_selfserve_vehicle_conditions'); + expect($moduleSelfServeRoute)->toContain('requireInProgressWashDetailsAccess'); + expect($moduleSelfServeRoute)->toContain('scopeInProgressWashResponseForCustomer'); expect($moduleSelfServeRoute)->toContain("'in_progress' => true"); expect($moduleSelfServeRoute)->toContain("'in_progress' => false"); expect($moduleSelfServeRoute)->toContain('selfserve_lane_status::OCCUPIED'); @@ -169,6 +279,111 @@ it('wires in-progress self-serve wash details endpoint', function (): void { expect($moduleSelfServeRoute)->toContain("'wash_started_at' =>"); }); +it('wires self-serve session management endpoints and OpenAPI coverage', function (): void { + $moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); + $openApiFiles = [app_path('openapi.yaml')]; + $rootOpenApiFile = dirname(app_path(), 3) . '/openapi.yaml'; + + if (is_file($rootOpenApiFile)) { + $openApiFiles[] = $rootOpenApiFile; + } + + expect($moduleSelfServeRoute)->not->toBeFalse(); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/sessions'); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/sessions/{id}'); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/force/stop'); + expect($moduleSelfServeRoute)->toContain('modules_selfserve_sessions_view'); + expect($moduleSelfServeRoute)->toContain('modules_selfserve_sessions_force_stop'); + expect($moduleSelfServeRoute)->toContain('modules_selfserve_sessions_force_stop_bill'); + expect($moduleSelfServeRoute)->toContain('listObjectsWithPaginationIfSet'); + expect($moduleSelfServeRoute)->toContain('getSessionSummary($session_id)'); + expect($moduleSelfServeRoute)->toContain('forceStopLane($lane_id, $session_id, $bill, $reason, $user_id)'); + + foreach ($openApiFiles as $openApiFile) { + $openApi = file_get_contents($openApiFile); + + expect($openApi)->not->toBeFalse(); + expect($openApi)->toContain('/modules/self-serve/sessions:'); + expect($openApi)->toContain('/modules/self-serve/sessions/{id}:'); + expect($openApi)->toContain('/modules/self-serve/lane/force/stop:'); + expect($openApi)->toContain('listSelfServeSessions'); + expect($openApi)->toContain('getSelfServeSessionDetail'); + expect($openApi)->toContain('forceStopSelfServeLane'); + expect($openApi)->toContain('SelfserveForceStopResponse'); + expect($openApi)->toContain('FORCE_STOPPED'); + expect($openApi)->toContain('SESSION_FORCE_STOPPED'); + } +}); + +it('returns authoritative allowed service state from self-serve session summaries', function (): void { + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + + expect($washFlow)->not->toBeFalse(); + $start = strpos($washFlow, 'public function getSessionSummary'); + $end = strpos($washFlow, 'public function getLatestSessionSummary', $start); + expect($start)->not->toBeFalse(); + expect($end)->not->toBeFalse(); + $summarySource = substr($washFlow, $start, $end - $start); + + expect($summarySource) + ->toContain('$metadata = is_array($session->metadata_json->value())') + ->toContain('$allowedServices = $this->normalizeServiceNames(') + ->toContain('$tasks = $this->filterTasksForAllowedServices($tasks, $allowedServices)') + ->toContain("'allowed_services' => \$allowedServices") + ->toContain("'machine_available' => \$machineAvailable") + ->toContain("'all_visible_questions_answered' => \$allVisibleQuestionsAnswered") + ->toContain("'allowed' => (bool)\$session->allowed->value()"); +}); + +it('filters machine button tasks out of self-serve snapshots when MACHINE is not allowed', function (): void { + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + + expect($washFlow)->not->toBeFalse() + ->and($washFlow)->toContain('$visibleTasks = $this->filterTasksForAllowedServices($activeTasks, $allowedServices)') + ->and($washFlow)->toContain("'tasks' => \$visibleTasks") + ->and($washFlow)->toContain('protected function filterTasksForAllowedServices') + ->and($washFlow)->toContain('protected function taskUsesMachineControls') + ->and($washFlow)->toContain("in_array(selfserve_lane_services::MACHINE->name, \$allowedServices, true)") + ->and($washFlow)->toContain("\$this->normalizeButtonList(\$task['buttons'] ?? null) !== []") + ->and($washFlow)->toContain("\$task['dynamic_images_vehicle_type'] ?? null"); +}); + +it('keeps self-serve force stop distinct from normal STOP relay and gate behavior', function (): void { + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + $sessionObject = file_get_contents(app_path('objects/selfserve_wash_sessions_o.php')); + $statusEnum = file_get_contents(app_path('modules/selfserve/helpers/selfserve_wash_session_status.php')); + $eventEnum = file_get_contents(app_path('modules/selfserve/helpers/selfserve_wash_event_type.php')); + + expect($washFlow)->not->toBeFalse(); + expect($sessionObject)->not->toBeFalse(); + expect($statusEnum)->toContain('FORCE_STOPPED'); + expect($eventEnum)->toContain('SESSION_FORCE_STOPPED'); + expect($sessionObject)->toContain('markForceStopped'); + expect($sessionObject)->toContain("metadata_json->set(\$existing)"); + + $start = strpos($washFlow, 'public function forceStopLane'); + $end = strpos($washFlow, 'protected function buildEligibilitySnapshot', $start); + expect($start)->not->toBeFalse(); + expect($end)->not->toBeFalse(); + $forceStopSource = substr($washFlow, $start, $end - $start); + + expect($forceStopSource)->toContain('$lane->invoice()'); + expect($forceStopSource)->toContain('getLastInvoiceOrderId'); + expect($forceStopSource)->toContain('markForceStopped'); + expect($forceStopSource)->toContain('SESSION_FORCE_STOPPED'); + expect($forceStopSource)->toContain('selfserve_lane_command::RESET'); + expect($forceStopSource)->toContain('runtime_before_reset'); + expect($forceStopSource)->not->toContain('selfserve_lane_command::STOP'); + expect($forceStopSource)->not->toContain('selfserve_lane_port::EXIT'); + expect($forceStopSource)->not->toContain('open('); + expect($forceStopSource)->not->toContain('turnOffRelaysAfterStop'); + expect($forceStopSource)->not->toContain('disableMachineRelayForCompletedWash'); + expect($forceStopSource)->not->toContain('getRelayStatus'); + expect($forceStopSource)->not->toContain('MACHINE_PROGRAM_PICKER'); + expect($forceStopSource)->not->toContain('addVehicleTypeProductToInvoiceIfNeeded'); + expect($forceStopSource)->not->toContain('addVehicleTypeProductToLastInvoiceOrder'); +}); + it('wires vehicle type override into self-serve preview and synchronization routes', function (): void { $vehicleConditionsRoute = file_get_contents(app_path('routes/departmentSelfserveVehicleConditionsRoute.php')); $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); @@ -181,8 +396,10 @@ it('wires vehicle type override into self-serve preview and synchronization rout expect($vehicleConditionsRoute)->toContain('normalizeVehicleTypeOverride'); expect($vehicleConditionsRoute)->toContain('shouldRefreshSummaryForVehicleType'); expect($vehicleConditionsRoute)->toContain('previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)'); - expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id)'); - expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane, $reg, $customer_id, true, $vehicle_type_id)'); + expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false)'); + expect($vehicleConditionsRoute)->toContain('requestBooleanFlag'); + expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state)'); + expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane_id, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state)'); expect($washFlow)->not->toBeFalse(); expect($washFlow)->toContain('resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride)'); @@ -190,3 +407,36 @@ it('wires vehicle type override into self-serve preview and synchronization rout expect($washFlow)->toContain("\$session->vehicle_type_id->set(\$snapshot['vehicle_type_id']);"); expect($washFlow)->toContain("\$session->vehicle_id->set(\$snapshot['vehicle']['id'] ?? null);"); }); + +it('blocks new self-serve eligibility and session sync for disabled lanes', function (): void { + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + $commandTrait = file_get_contents(app_path('modules/selfserve/traits/selfserve_lane_command_t.php')); + + expect($washFlow)->not->toBeFalse() + ->and($washFlow)->toContain('if (!$lane->isSelfServeEnabled())') + ->and($washFlow)->toContain("'allowed_services' => []") + ->and($washFlow)->toContain("'machine_available' => false") + ->and($washFlow)->toContain("'allowed' => false") + ->and($washFlow)->toContain("'blocked_reason' => 'Self-serve is disabled for this lane.'") + ->and($washFlow)->toContain("'blocking_reasons' => ['LANE_SELFSERVE_DISABLED']") + ->and($washFlow)->toContain("'disabled_lane' => true") + ->and($washFlow)->toContain('formatBlockedSessionSummary') + ->and($washFlow)->toContain('empty($summary[\'session\'][\'id\'])'); + + expect($commandTrait)->not->toBeFalse() + ->and($commandTrait)->toContain('$this->department_lane->isSelfServeEnabled()') + ->and($commandTrait)->toContain('Self-serve is not enabled for this lane.'); +}); + +it('keeps read-only self-serve preview and summary refreshes from touching relay hardware', function (): void { + $vehicleConditionsRoute = file_get_contents(app_path('routes/departmentSelfserveVehicleConditionsRoute.php')); + + expect($vehicleConditionsRoute)->not->toBeFalse(); + expect($vehicleConditionsRoute)->toContain('$flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false);'); + expect($vehicleConditionsRoute)->toContain('$summary = $flow->synchronizeSession('); + expect($vehicleConditionsRoute)->toContain('$summary = $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false);'); + expect($vehicleConditionsRoute)->toContain('$activate_machine = $this->requestBooleanFlag(\'activate_machine\', true);'); + expect($vehicleConditionsRoute)->toContain('$sync_relay_state = $this->requestBooleanFlag(\'sync_relay_state\', true);'); + expect($vehicleConditionsRoute)->toContain('$this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);'); + expect($vehicleConditionsRoute)->toContain('$this->getWashFlow()->synchronizeSession($lane_id, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php index 012894a8..dc0f9776 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveSchemaBootstrapCompatibilityTest.php @@ -17,3 +17,46 @@ it('adds wash_started_at column for legacy selfserve wash session schemas', func expect($bootstrapContent)->toContain("'wash_started_at'"); expect($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at'); }); + +it('adds lane-level self-serve enablement for existing department lanes', function (): void { + $bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php')); + + expect($bootstrapContent)->not->toBeFalse(); + expect($bootstrapContent)->toContain("'department_lanes'"); + expect($bootstrapContent)->toContain("'selfserve_enabled'"); + expect($bootstrapContent)->toContain('ALTER TABLE department_lanes ADD COLUMN selfserve_enabled TINYINT(1) NOT NULL DEFAULT 1 AFTER machine_type_id'); +}); + +it('creates canvas-only self-serve studio layout storage', function (): void { + $bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php')); + + expect($bootstrapContent)->not->toBeFalse(); + expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS department_selfserve_studio_layouts'); + expect($bootstrapContent)->toContain('layout_json JSON NOT NULL'); + expect($bootstrapContent)->toContain('idx_department_selfserve_studio_layouts_department_user'); +}); + +it('uses mysql-safe identifiers for self-serve studio virtual hardware storage', function (): void { + $bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php')); + + expect($bootstrapContent)->not->toBeFalse(); + expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS department_selfserve_studio_virtual_hardware'); + expect($bootstrapContent)->toContain('UNIQUE KEY uniq_selfserve_vhw_department'); + expect($bootstrapContent)->toContain('INDEX idx_selfserve_vhw_dept_updated'); + expect($bootstrapContent)->not->toContain('idx_department_selfserve_studio_virtual_hardware_department_updated'); + + preg_match_all('/\b(?:UNIQUE\s+KEY|INDEX)\s+([a-zA-Z0-9_]+)/', $bootstrapContent, $matches); + foreach ($matches[1] as $identifier) { + expect(strlen($identifier))->toBeLessThanOrEqual(64); + } +}); + +it('creates self-serve studio path confirmation storage', function (): void { + $bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php')); + + expect($bootstrapContent)->not->toBeFalse(); + expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS department_selfserve_path_confirmations'); + expect($bootstrapContent)->toContain('path_signature VARCHAR(128) NOT NULL'); + expect($bootstrapContent)->toContain('result_signature VARCHAR(128) NOT NULL'); + expect($bootstrapContent)->toContain('idx_selfserve_path_conf_signature'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveStartCleanerRelayWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveStartCleanerRelayWiringTest.php index eb5186cf..f812cf00 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveStartCleanerRelayWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveStartCleanerRelayWiringTest.php @@ -22,6 +22,11 @@ it('keeps cleaner relay enable wired into machine relay start paths', function ( expect($enableMachineRelayMethodOffset)->not->toBeFalse(); $enableMachineRelayMethod = substr($washFlow, (int)$enableMachineRelayMethodOffset, 1200); expect($enableMachineRelayMethod)->toContain('$this->enableCleanerRelayForStartedWash($lane);'); + $cleanerEnableOffset = strpos($enableMachineRelayMethod, '$this->enableCleanerRelayForStartedWash($lane);'); + $alreadyEnabledGuardOffset = strpos($enableMachineRelayMethod, 'if ((bool)$session->machine_relay_enabled->value() === true)'); + expect($cleanerEnableOffset)->not->toBeFalse() + ->and($alreadyEnabledGuardOffset)->not->toBeFalse() + ->and($cleanerEnableOffset)->toBeLessThan($alreadyEnabledGuardOffset); expect($moduleRoute)->not->toBeFalse(); $machineEnableRouteOffset = strpos($moduleRoute, '/modules/self-serve/lane/relay/machine/enable'); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioActionRunnerTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioActionRunnerTest.php new file mode 100644 index 00000000..500e875a --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioActionRunnerTest.php @@ -0,0 +1,72 @@ + 17, + 'department_lane' => (object) [ + 'department' => new class { + public function value(): int { return 9; } + }, + 'machine_type_id' => new class { + public function value(): int { return 2; } + }, + ], + ]; + + $config = [ + 'actions' => [ + [ + 'id' => 501, + 'name' => 'Open exit port when condition true', + 'enabled' => true, + 'event' => selfserve_studio_actions::EVENT_WASH_START_COMMAND, + 'wash_mode' => selfserve_studio_actions::MODE_BOTH, + 'department' => 9, + 'lane' => 17, + 'product' => 0, + 'machine_type_id' => null, + 'condition_id' => 7, + 'order_priority' => 1, + 'operation' => selfserve_studio_actions::OP_OPEN_LANE_EXIT_PORT, + 'options' => [], + ], + ], + ]; + + $missingResults = $runner->matchingActions( + $config, + $lane, + selfserve_studio_actions::EVENT_WASH_START_COMMAND, + selfserve_studio_actions::MODE_MANUAL, + [] + ); + + $falseResults = $runner->matchingActions( + $config, + $lane, + selfserve_studio_actions::EVENT_WASH_START_COMMAND, + selfserve_studio_actions::MODE_MANUAL, + ['condition_results' => [7 => false]] + ); + + $trueResults = $runner->matchingActions( + $config, + $lane, + selfserve_studio_actions::EVENT_WASH_START_COMMAND, + selfserve_studio_actions::MODE_MANUAL, + ['condition_results' => [7 => true]] + ); + + expect($missingResults)->toHaveCount(0); + expect($falseResults)->toHaveCount(0); + expect($trueResults)->toHaveCount(1); + expect($trueResults[0]['id'])->toBe(501); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php new file mode 100644 index 00000000..d07611a7 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php @@ -0,0 +1,1894 @@ +newInstanceWithoutConstructor(); +} + +function selfserve_wash_flow_without_constructor(): selfserve_wash_flow +{ + $reflection = new ReflectionClass(selfserve_wash_flow::class); + return $reflection->newInstanceWithoutConstructor(); +} + +function selfserve_virtual_hardware_without_constructor(): selfserve_virtual_hardware +{ + $reflection = new ReflectionClass(selfserve_virtual_hardware::class); + return $reflection->newInstanceWithoutConstructor(); +} + +it('serializes questions, conditions, tasks, scopes, and gateways into one graph', function (): void { + $service = selfserve_studio_graph_without_constructor(); + + $graph = $service->buildGraphFromConfig([ + 'questions' => [ + ['id' => 1, 'question' => 'Are mirrors folded?', 'condition_id' => 10, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1], + ], + 'conditions' => [ + ['id' => 10, 'name' => 'Trailer present', 'condition_id' => null, 'lane' => 7, 'product' => 3, 'department' => 2], + ], + 'rules' => [ + ['id' => 20, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1, 'name' => 'Mirror answer'], + ], + 'tasks' => [ + ['id' => 30, 'task' => 'Fold mirrors', 'gate_type' => 'QUESTION', 'gate_ref_id' => 1, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1, 'services' => ['MACHINE']], + ], + ], [ + 'lookups' => [ + 'departments' => [['id' => 2, 'label' => 'Roskilde']], + 'lanes' => [['id' => 7, 'label' => 'Lane 7']], + 'products' => [['id' => 3, 'label' => 'Forvogn']], + 'machine_types' => [], + 'vehicle_types' => [['id' => 3, 'product' => 3, 'label' => 'Forvogn', 'source' => 'products']], + 'labels' => [ + 'departments' => ['2' => 'Roskilde'], + 'lanes' => ['7' => 'Lane 7'], + 'products' => ['3' => 'Forvogn'], + 'vehicle_types' => ['3' => 'Forvogn'], + 'questions' => ['1' => 'Are mirrors folded?'], + 'conditions' => ['10' => 'Trailer present'], + 'tasks' => ['30' => 'Fold mirrors'], + ], + ], + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 50, + 'label' => 'Gateway A', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'relay-1', 'label' => 'Machine relay', 'role' => 'MACHINE'], + ], + ], + ], + 'relays' => [ + ['relay_id' => 'relay-1', 'name' => 'Machine relay'], + ], + 'lanes' => [ + [ + 'id' => 7, + 'relay_slots' => [ + ['relay_id' => 'relay-1', 'slot' => 'MACHINE'], + ], + ], + ], + ], + ]); + + $nodeIds = array_column($graph['nodes'], 'id'); + $edgeIds = array_column($graph['edges'], 'id'); + + expect($nodeIds)->toContain('question:1'); + expect($nodeIds)->toContain('condition:10'); + expect($nodeIds)->toContain('rule:20'); + expect($nodeIds)->toContain('task:30'); + expect($nodeIds)->toContain('vehicle_type:3'); + expect($nodeIds)->toContain('gateway:50'); + expect($nodeIds)->toContain('relay:relay-1'); + expect($edgeIds)->toContain('question-gate:10:1'); + expect($edgeIds)->toContain('task-gate:question:1:30'); + expect($edgeIds)->toContain('scope:vehicle_type:3:question:1'); + expect($edgeIds)->toContain('scope:vehicle_type:3:condition:10'); + expect($edgeIds)->toContain('scope:vehicle_type:3:task:30'); + expect($edgeIds)->toContain('gateway-binding:50:relay-1:0'); + expect($edgeIds)->toContain('task-service:30:MACHINE:50:relay-1:0'); + expect($edgeIds)->toContain('relay-lane:relay-1:7:MACHINE'); + + $taskNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'task:30' + ))[0] ?? null; + $bindingNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'binding:50:relay-1:0' + ))[0] ?? null; + + expect($taskNode['data']['raw']['services'] ?? [])->toBe(['MACHINE']); + expect($bindingNode['data']['raw']['services'] ?? [])->toBe(['MACHINE']); +}); + +it('serializes configurable studio actions with event, gate, scope, and ordering edges', function (): void { + $service = selfserve_studio_graph_without_constructor(); + + $graph = $service->buildGraphFromConfig([ + 'schema_version' => 2, + 'questions' => [], + 'conditions' => [ + ['id' => 10, 'name' => 'Machine selected'], + ], + 'rules' => [], + 'tasks' => [], + 'actions' => [ + [ + 'id' => 80, + 'name' => 'Open lane entry', + 'event' => 'wash_start_command', + 'wash_mode' => 'both', + 'operation' => 'open_lane_entrance_port', + 'condition_id' => 10, + 'lane' => 7, + 'order_priority' => 1, + ], + [ + 'id' => 81, + 'name' => 'Cleaner off', + 'event' => 'wash_start_command', + 'wash_mode' => 'machine', + 'operation' => 'set_cleaner_relay', + 'relay_state' => false, + 'lane' => 7, + 'order_priority' => 2, + ], + ], + ], [ + 'lookups' => [ + 'labels' => [ + 'lanes' => ['7' => 'Lane 7'], + 'conditions' => ['10' => 'Machine selected'], + 'actions' => ['80' => 'Open lane entry', '81' => 'Cleaner off'], + ], + ], + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 50, + 'label' => 'Gateway A', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'ENTRY-7', 'label' => 'Entry relay', 'role' => 'ENTRY', 'services' => ['ENTRY']], + ['relay_id' => 'CLEAN-7', 'label' => 'Cleaner relay', 'role' => 'CLEANER', 'services' => ['CLEANER']], + ], + ], + ], + 'relays' => [ + ['relay_id' => 'ENTRY-7', 'name' => 'Entry relay'], + ['relay_id' => 'CLEAN-7', 'name' => 'Cleaner relay'], + ], + 'lanes' => [ + [ + 'id' => 7, + 'relay_slots' => [ + ['relay_id' => 'ENTRY-7', 'slot' => 'ENTRY'], + ['relay_id' => 'CLEAN-7', 'slot' => 'CLEANER'], + ], + ], + ], + ], + ]); + + $nodeIds = array_column($graph['nodes'], 'id'); + $edgeIds = array_column($graph['edges'], 'id'); + $actionNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'action:81' + ))[0] ?? null; + + expect($nodeIds)->toContain('action:80') + ->and($nodeIds)->toContain('action:81') + ->and($edgeIds)->toContain('action-event:wash_start_command:80') + ->and($edgeIds)->toContain('action-gate:10:80') + ->and($edgeIds)->toContain('action-order:wash_start_command:80:81') + ->and($edgeIds)->toContain('action-relay:80:ENTRY-7:ENTRY:7') + ->and($edgeIds)->toContain('action-relay:81:CLEAN-7:CLEANER:7') + ->and($edgeIds)->toContain('scope:lane:7:action:80') + ->and($actionNode['data']['action_label'])->toBe('Turn OFF CLEANER') + ->and($actionNode['data']['relay_role'])->toBe('CLEANER'); +}); + +it('validates action configuration and keeps warnings non-blocking', function (): void { + $versioning = new class extends selfserve_config_versioning { + public function __construct() + { + } + }; + + $validation = $versioning->validateConfig([ + 'schema_version' => 2, + 'questions' => [ + ['id' => 1, 'question' => 'Machine selected?'], + ], + 'conditions' => [ + [ + 'id' => 10, + 'name' => 'Gate', + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'], + ], + ], + ], + ], + 'rules' => [], + 'tasks' => [], + 'actions' => [ + [ + 'id' => 80, + 'name' => 'Manual machine start action', + 'event' => 'machine_start_triggered', + 'wash_mode' => 'manual', + 'operation' => 'set_machine_relay', + 'condition_id' => 10, + 'relay_state' => true, + 'options' => ['failure_policy' => 'block'], + ], + ], + ]); + + expect($validation['valid'])->toBeTrue() + ->and($validation['stats']['actions'])->toBe(1) + ->and(implode("\n", $validation['warnings']))->toContain('uses manual mode for the machine-start event'); +}); + +it('serializes v2 condition expressions without standalone rule nodes', function (): void { + $service = selfserve_studio_graph_without_constructor(); + + $graph = $service->buildGraphFromConfig([ + 'schema_version' => 2, + 'questions' => [ + ['id' => 1, 'question' => 'Are mirrors folded?', 'condition_id' => null, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1], + ], + 'conditions' => [ + [ + 'id' => 10, + 'name' => 'Ready', + 'lane' => 7, + 'product' => 3, + 'department' => 2, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'children' => [ + ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'], + ], + ], + ], + ], + 'rules' => [ + ['id' => 99, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1], + ], + 'tasks' => [], + ], [ + 'lookups' => [ + 'labels' => [ + 'questions' => ['1' => 'Are mirrors folded?'], + 'conditions' => ['10' => 'Ready'], + ], + ], + ]); + + $nodeIds = array_column($graph['nodes'], 'id'); + $edgeIds = array_column($graph['edges'], 'id'); + $conditionNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'condition:10' + ))[0] ?? null; + + expect($nodeIds)->toContain('condition:10'); + expect($nodeIds)->not->toContain('rule:99'); + expect($edgeIds)->toContain('expression:10:question:1:' . substr(md5('0.0'), 0, 8)); + expect($conditionNode['data']['expression_summary'] ?? null)->toContain('Question 1'); +}); + +it('serializes branch and case condition expression dependencies', function (): void { + $service = selfserve_studio_graph_without_constructor(); + + $graph = $service->buildGraphFromConfig([ + 'schema_version' => 2, + 'questions' => [ + ['id' => 1, 'question' => 'Has booking?'], + ['id' => 2, 'question' => 'Allowed?'], + ], + 'conditions' => [ + [ + 'id' => 10, + 'name' => 'Branch condition', + 'expression' => [ + 'type' => 'branch', + 'branches' => [ + [ + 'kind' => 'if', + 'when' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'], + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'], + ], + [ + 'kind' => 'else', + 'else' => true, + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_FALSE'], + ], + ], + ], + ], + [ + 'id' => 20, + 'name' => 'Case condition', + 'expression' => [ + 'type' => 'case', + 'subject_type' => 'condition', + 'subject_id' => 10, + 'cases' => [ + [ + 'value' => true, + 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'], + ], + ], + ], + ], + ], + 'rules' => [], + 'tasks' => [], + ]); + + $edgeIds = array_column($graph['edges'], 'id'); + $conditionNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'condition:20' + ))[0] ?? null; + + expect($edgeIds)->toContain('expression:10:question:1:' . substr(md5('0.b0.when'), 0, 8)) + ->and($edgeIds)->toContain('expression:10:question:2:' . substr(md5('0.b0.then'), 0, 8)) + ->and($edgeIds)->toContain('expression:20:condition:10:' . substr(md5('0.case'), 0, 8)) + ->and($edgeIds)->toContain('expression:20:question:2:' . substr(md5('0.c0.then'), 0, 8)) + ->and($conditionNode['data']['expression_summary'] ?? null)->toContain('Case Condition 10'); +}); + +it('keeps runtime on published v2 configs and leaves draft JSON as the studio edit surface', function (): void { + $washFlowSource = file_get_contents((new ReflectionClass(selfserve_wash_flow::class))->getFileName()); + $studioGraphSource = file_get_contents((new ReflectionClass(selfserve_studio_graph::class))->getFileName()); + + expect($washFlowSource)->toContain('getPublishedV2Config($departmentId)'); + expect($washFlowSource)->toContain("\$configSource = \$publishedConfigPayload === null ? 'legacy' : 'published';"); + expect($studioGraphSource)->toContain('$draftObject->config_json->set($config);'); + expect($studioGraphSource)->toContain('Standalone rule operations are not supported in self-serve rules v2.'); + expect($studioGraphSource)->toContain('upsert_path'); +}); + +it('upserts path editor answers into generated condition and task config rows', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $method = new ReflectionMethod(selfserve_studio_graph::class, 'applyConfigOperation'); + $method->setAccessible(true); + $config = [ + 'schema_version' => 2, + 'questions' => [ + ['id' => 11, 'question' => 'Machine wash is allowed', 'order_priority' => 1], + ['id' => 12, 'question' => 'Trailer present', 'order_priority' => 2], + ], + 'conditions' => [], + 'rules' => [], + 'tasks' => [], + 'actions' => [], + 'v2_meta' => ['next_ids' => ['condition' => 100, 'task' => 200]], + ]; + + $method->invokeArgs($service, [6, &$config, [ + 'action' => 'upsert_path', + 'entity' => 'path', + 'data' => [ + 'path_key' => 'allowed_front', + 'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001], + 'answers' => [ + ['question_id' => 11, 'value' => true], + ['question_id' => 12, 'value' => false], + ], + 'result' => [ + 'machine_allowed' => true, + 'task' => 'Set program', + 'services' => ['MACHINE', 'PROGRAM_PICKER'], + 'buttons' => ['program_picker', 'reset', 2, 'start'], + 'dynamic_images_vehicle_type' => 3, + 'tasks' => [ + [ + 'task' => 'Set program', + 'services' => ['MACHINE', 'PROGRAM_PICKER'], + 'buttons' => ['program_picker'], + 'dynamic_images_vehicle_type' => 3, + ], + [ + 'task' => 'Press reset', + 'services' => ['MACHINE'], + 'buttons' => ['reset'], + ], + [ + 'task' => 'Press machine button 2', + 'services' => ['MACHINE'], + 'buttons' => [2], + ], + [ + 'task' => 'Press start', + 'services' => ['MACHINE'], + 'buttons' => ['start'], + ], + ], + ], + ], + ]]); + + $condition = $config['conditions'][0] ?? []; + $tasks = array_values($config['tasks'] ?? []); + $pathMeta = $config['v2_meta']['path_editor']['paths']['allowed_front'] ?? []; + $validation = (new class extends selfserve_config_versioning { + public function __construct() + { + } + })->validateConfig($config); + + expect($condition['generated_by'])->toBe('path_editor') + ->and($condition['path_key'])->toBe('allowed_front') + ->and($condition['lane'])->toBe(7) + ->and($condition['product'])->toBe(2) + ->and($condition['machine_type_id'])->toBe(1001) + ->and($condition['expression']['children'])->toHaveCount(2) + ->and($condition['expression']['children'][0]['operator'])->toBe('IS_TRUE') + ->and($condition['expression']['children'][1]['operator'])->toBe('IS_FALSE') + ->and($tasks)->toHaveCount(4) + ->and(array_column($tasks, 'task'))->toBe(['Set program', 'Press reset', 'Press machine button 2', 'Press start']) + ->and(array_column($tasks, 'order_priority'))->toBe([10, 20, 30, 40]) + ->and(array_column($tasks, 'gate_ref_id'))->toBe([(int)$condition['id'], (int)$condition['id'], (int)$condition['id'], (int)$condition['id']]) + ->and($tasks[0]['generated_by'])->toBe('path_editor') + ->and($tasks[0]['gate_type'])->toBe('CONDITION') + ->and($tasks[0]['services'])->toBe(['MACHINE', 'PROGRAM_PICKER']) + ->and($tasks[0]['buttons'])->toBe(['program_picker']) + ->and($tasks[0]['dynamic_images_vehicle_type'])->toBe(3) + ->and($tasks[1]['buttons'])->toBe(['reset']) + ->and($tasks[2]['buttons'])->toBe([2]) + ->and($tasks[3]['buttons'])->toBe(['start']) + ->and($pathMeta['condition_id'])->toBe((int)$condition['id']) + ->and($pathMeta['task_id'])->toBe((int)$tasks[0]['id']) + ->and($pathMeta['task_ids'])->toBe(array_map(static fn(array $task): int => (int)$task['id'], $tasks)) + ->and($pathMeta['result']['buttons'])->toBe(['program_picker', 'reset', 2, 'start']) + ->and($pathMeta['path_signature'])->not->toBe('') + ->and($validation['valid'])->toBeTrue(); + + $method->invokeArgs($service, [6, &$config, [ + 'action' => 'upsert_path', + 'entity' => 'path', + 'data' => [ + 'path_key' => 'allowed_front', + 'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001], + 'answers' => [ + ['question_id' => 11, 'value' => true], + ['question_id' => 12, 'value' => false], + ], + 'result' => [ + 'machine_allowed' => true, + 'task' => 'Set program', + 'services' => ['MACHINE', 'PROGRAM_PICKER'], + 'buttons' => ['program_picker', 'start'], + 'dynamic_images_vehicle_type' => 3, + 'tasks' => [ + ['task' => 'Set program', 'services' => ['MACHINE', 'PROGRAM_PICKER'], 'buttons' => ['program_picker'], 'dynamic_images_vehicle_type' => 3], + ['task' => 'Press start', 'services' => ['MACHINE'], 'buttons' => ['start']], + ], + ], + ], + ]]); + + $updatedTasks = array_values(array_filter( + $config['tasks'] ?? [], + static fn(array $task): bool => ($task['path_key'] ?? '') === 'allowed_front' + )); + $updatedPathMeta = $config['v2_meta']['path_editor']['paths']['allowed_front'] ?? []; + + expect($updatedTasks)->toHaveCount(2) + ->and(array_column($updatedTasks, 'id'))->toBe([(int)$tasks[0]['id'], (int)$tasks[1]['id']]) + ->and(array_column($updatedTasks, 'task'))->toBe(['Set program', 'Press start']) + ->and($updatedPathMeta['task_ids'])->toBe([(int)$tasks[0]['id'], (int)$tasks[1]['id']]); + + $method->invokeArgs($service, [6, &$config, [ + 'action' => 'upsert_path', + 'entity' => 'path', + 'data' => [ + 'path_key' => 'legacy_front', + 'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001], + 'answers' => [ + ['question_id' => 11, 'value' => false], + ], + 'result' => [ + 'machine_allowed' => true, + 'task' => 'Legacy start', + 'services' => ['MACHINE'], + 'buttons' => ['start'], + ], + ], + ]]); + + $legacyPathMeta = $config['v2_meta']['path_editor']['paths']['legacy_front'] ?? []; + $legacyTask = array_values(array_filter( + $config['tasks'] ?? [], + static fn(array $task): bool => ($task['path_key'] ?? '') === 'legacy_front' + ))[0] ?? []; + + expect($legacyTask['task'])->toBe('Legacy start') + ->and($legacyTask['buttons'])->toBe(['start']) + ->and($legacyPathMeta['task_id'])->toBe((int)$legacyTask['id']) + ->and($legacyPathMeta['task_ids'])->toBe([(int)$legacyTask['id']]); +}); + +it('surfaces task attachments in studio graph, simulator, and flow responses', function (): void { + $washFlowSource = file_get_contents((new ReflectionClass(selfserve_wash_flow::class))->getFileName()); + $studioGraphSource = file_get_contents((new ReflectionClass(selfserve_studio_graph::class))->getFileName()); + $attachmentPayloadSource = file_get_contents(WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php'); + + expect($washFlowSource)->toContain("require_once WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php';") + ->and($washFlowSource)->toContain('(new selfserve_task_attachment_payloads())->attachToTasks(') + ->and($washFlowSource)->toContain("'attachments' => \$task['attachments'] ?? []") + ->and($washFlowSource)->toContain("'attachments' => \$taskAttachments") + ->and($studioGraphSource)->toContain('$configWithAttachments = $this->withTaskAttachments($config);') + ->and($studioGraphSource)->toContain('$task[\'attachments\'] = is_array($task[\'attachments\'] ?? null) ? array_values($task[\'attachments\']) : [];') + ->and($attachmentPayloadSource)->toContain("private const OBJECT_TYPE = 'department_selfserve_tasks';") + ->and($attachmentPayloadSource)->toContain('listMany(self::OBJECT_TYPE, $taskIds)') + ->and($attachmentPayloadSource)->toContain('generateDirectDownloadUrl($fileName)'); +}); + +it('derives studio vehicle type lookup rows from selectable wash products', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $method = new ReflectionMethod(selfserve_studio_graph::class, 'vehicleTypeRowsFromProducts'); + + $vehicleTypes = $method->invoke($service, [ + ['id' => 3, 'name' => 'Forvogn', 'description' => 'Front vehicle', 'is_wash' => 1, 'subscription_allowed' => 1, 'order_priority' => 10], + ['id' => 4, 'name' => 'Trækker', 'description' => 'Tractor unit', 'is_wash' => '1', 'subscription_allowed' => '1', 'order_priority' => 20], + ['id' => 5, 'name' => 'Addon', 'description' => '', 'is_wash' => 0, 'subscription_allowed' => 1, 'order_priority' => 30], + ['id' => 6, 'name' => 'Internal wash', 'description' => '', 'is_wash' => 1, 'subscription_allowed' => 0, 'order_priority' => 40], + ]); + + expect(array_column($vehicleTypes, 'id'))->toBe([3, 4]); + expect(array_column($vehicleTypes, 'label'))->toBe(['Forvogn', 'Trækker']); + expect($vehicleTypes[0]['product'])->toBe(3); + expect($vehicleTypes[0]['product_id'])->toBe(3); + expect($vehicleTypes[0]['source'])->toBe('products'); +}); + +it('exposes dynamic images and referenced machine types as studio lookup choices', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $dynamicImages = new ReflectionMethod(selfserve_studio_graph::class, 'dynamicImageRowsFromLanes'); + $machineTypes = new ReflectionMethod(selfserve_studio_graph::class, 'addReferencedMachineTypeRows'); + + $dynamicImageRows = $dynamicImages->invoke($service, [ + ['id' => 7, 'label' => 'Lane 7', 'dynamic_image_id' => 1], + ['id' => 8, 'label' => 'Lane 8', 'dynamic_image_id' => 9], + ]); + $machineTypeRows = $machineTypes->invoke( + $service, + [ + ['id' => 1001, 'name' => 'Portal', 'label' => 'Portal'], + ], + [ + ['id' => 7, 'machine_type_id' => 2002], + ], + [ + 'conditions' => [ + ['id' => 21, 'machine_type_id' => 3003], + ], + 'tasks' => [ + ['id' => 41, 'machine_type_id' => 1001], + ], + ] + ); + + expect(array_column($dynamicImageRows, 'id'))->toBe([1, 9]); + expect($dynamicImageRows[0]['label'])->toBe('Machine 1'); + expect($dynamicImageRows[1]['label'])->toBe('Dynamic image 9'); + expect(array_column($machineTypeRows, 'id'))->toBe([1001, 2002, 3003]); + expect($machineTypeRows[0]['label'])->toBe('Portal'); + expect($machineTypeRows[1]['label'])->toBe('Machine type 2002'); + expect($machineTypeRows[2]['label'])->toBe('Machine type 3003'); +}); + +it('keeps lane management fields on lane scope nodes', function (): void { + $service = selfserve_studio_graph_without_constructor(); + + $graph = $service->buildGraphFromConfig([ + 'questions' => [], + 'conditions' => [], + 'rules' => [], + 'tasks' => [], + ], [ + 'lookups' => [ + 'lanes' => [ + [ + 'id' => 7, + 'department' => 6, + 'name' => 'Lane 7', + 'label' => 'Lane 7', + 'relay_machine_id' => 'M-7', + 'machine_type_id' => 1001, + 'dynamic_image_id' => 1, + 'selfserve_enabled' => true, + ], + ], + 'machine_types' => [['id' => 1001, 'label' => 'Portal']], + 'dynamic_images' => [['id' => 1, 'label' => 'Machine 1']], + 'labels' => [ + 'lanes' => ['7' => 'Lane 7'], + 'machine_types' => ['1001' => 'Portal'], + 'dynamic_images' => ['1' => 'Machine 1'], + ], + ], + ]); + + $laneNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'lane:7' + ))[0] ?? null; + + expect($laneNode)->not->toBeNull() + ->and($laneNode['data']['raw']['relay_machine_id'])->toBe('M-7') + ->and($laneNode['data']['raw']['machine_type_id'])->toBe(1001) + ->and($laneNode['data']['raw']['dynamic_image_id'])->toBe(1) + ->and($laneNode['data']['raw']['selfserve_enabled'])->toBeTrue(); +}); + +it('routes studio lane graph operations through department_lanes', function (): void { + $source = file_get_contents((new ReflectionClass(selfserve_studio_graph::class))->getFileName()); + + expect($source)->toContain("if (\$entity === 'lane')") + ->and($source)->toContain('private function applyLaneOperation') + ->and($source)->toContain('private function createLane') + ->and($source)->toContain('private function updateLane') + ->and($source)->toContain('INSERT INTO department_lanes') + ->and($source)->toContain("'dynamic_image_id'") + ->and($source)->toContain("'selfserve_enabled'") + ->and($source)->toContain('normalizeLaneField') + ->and($source)->toContain('normalizeSelfServeEnabledValue') + ->and($source)->toContain('disableSelfServeRelaysBestEffort'); +}); + +it('applies saved layout without changing graph semantics', function (): void { + $service = selfserve_studio_graph_without_constructor(); + + $graph = $service->buildGraphFromConfig([ + 'questions' => [ + ['id' => 1, 'question' => 'Question', 'order_priority' => 1], + ], + 'conditions' => [], + 'rules' => [], + 'tasks' => [], + ], [ + 'lookups' => [ + 'labels' => [], + ], + ], [ + 'nodes' => [ + 'question:1' => ['x' => 123, 'y' => 456], + ], + ]); + + $questionNode = array_values(array_filter( + $graph['nodes'], + static fn(array $node): bool => ($node['id'] ?? null) === 'question:1' + ))[0] ?? null; + + expect($questionNode)->not->toBeNull(); + expect($questionNode['position'])->toBe(['x' => 123.0, 'y' => 456.0]); +}); + +it('keeps layout loading compatible with native PDO named placeholders', function (): void { + $method = new ReflectionMethod(selfserve_studio_graph::class, 'loadLayout'); + $source = implode('', array_slice( + file((string)$method->getFileName()) ?: [], + $method->getStartLine() - 1, + $method->getEndLine() - $method->getStartLine() + 1 + )); + + expect($source)->not->toContain('user_id = :user_id OR user_id IS NULL'); + expect($source)->not->toContain('user_id = :user_id THEN 0 ELSE 1'); + expect($source)->toContain(':user_id_filter'); + expect($source)->toContain(':user_id_sort'); +}); + +it('builds guided simulator debug payload with blockers and canvas annotations', function (): void { + $service = selfserve_wash_flow_without_constructor(); + + $debug = $service->buildStudioDebugPayload(6, [ + 'lane' => ['id' => 7, 'name' => 'Lane 7'], + 'machine_type' => ['id' => 1001, 'name' => 'Portal'], + 'vehicle' => null, + 'reg' => 'TEST123', + 'customer_number' => null, + 'vehicle_type_id' => 2, + 'answers' => [11 => null], + 'answer_sources' => [11 => 'override'], + 'questions' => [], + 'tasks' => [], + 'allowed_services' => [], + 'machine_available' => false, + 'all_visible_questions_answered' => false, + 'allowed' => false, + 'config_version_id' => 90, + 'config_source' => 'draft', + 'evaluation_trace' => [ + 'visible_question_ids' => [11], + 'visibility_condition_results' => [21 => true], + 'condition_results' => [21 => true], + 'task_gates' => [ + ['task_id' => 41, 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'satisfied' => false], + ], + ], + 'debug_candidates' => [ + 'questions' => [ + ['id' => 11, 'question' => 'Are mirrors folded?', 'condition_id' => 21, 'order_priority' => 1], + ], + 'conditions' => [ + ['id' => 21, 'name' => 'Trailer present', 'condition_id' => null], + ], + 'rules' => [ + ['id' => 31, 'condition_id' => 21, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 11, 'name' => 'Mirror answer'], + ], + 'tasks' => [ + ['id' => 41, 'task' => 'Fold mirrors', 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'services' => ['MACHINE'], 'buttons' => [1], 'order_priority' => 1], + ], + ], + ], [ + 'lookups' => [ + 'labels' => [ + 'departments' => ['6' => 'Roskilde'], + 'lanes' => ['7' => 'Lane 7'], + 'vehicle_types' => ['2' => 'Forvogn'], + 'machine_types' => ['1001' => 'Portal'], + 'questions' => ['11' => 'Are mirrors folded?'], + 'conditions' => ['21' => 'Trailer present'], + 'rules' => ['31' => 'Mirror answer'], + 'tasks' => ['41' => 'Fold mirrors'], + ], + ], + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 701, + 'label' => 'Roskilde Edge', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'M-7', 'label' => 'Machine relay', 'role' => 'MACHINE', 'services' => ['MACHINE']], + ], + ], + ], + ], + 'graph' => [ + 'edges' => [ + ['id' => 'task-gate:question:11:41', 'source' => 'question:11', 'target' => 'task:41', 'label' => 'unlocks'], + ], + ], + ]); + + expect($debug['summary']['status'])->toBe('blocked') + ->and($debug['parameters']['config_source'])->toBe('draft') + ->and($debug['questions'][0]['state'])->toBe('missing') + ->and($debug['questions'][0]['answer_source'])->toBe('override') + ->and($debug['tasks'][0]['state'])->toBe('blocked') + ->and($debug['tasks'][0]['reason'])->toBe('Task Fold mirrors blocked because gate QUESTION Are mirrors folded? expected true, actual missing.') + ->and($debug['tasks'][0]['causes'][0])->toMatchArray([ + 'kind' => 'question', + 'id' => 11, + 'label' => 'Are mirrors folded?', + 'expected' => true, + 'actual' => null, + ]) + ->and($debug['dynamic_image_buttons'][0])->toMatchArray([ + 'kind' => 'dynamic_image_button', + 'label' => '1', + 'state' => 'hidden', + 'task_id' => 41, + ]) + ->and(array_column($debug['decisions'], 'kind'))->toContain('question') + ->and(array_column($debug['decisions'], 'kind'))->toContain('task') + ->and(array_column($debug['decisions'], 'kind'))->toContain('signal') + ->and(array_column($debug['decisions'], 'kind'))->toContain('dynamic_image_button') + ->and(array_column($debug['recommendations'], 'title'))->toContain('Answer required questions') + ->and(array_column($debug['recommendations'], 'title'))->toContain('Configure lane machine relay') + ->and($debug['graph_annotations']['nodes']['question:11']['state'])->toBe('warning') + ->and($debug['graph_annotations']['nodes']['lane:7']['state'])->toBe('error'); +}); + +it('uses program picker button numbers as thumb selectors in simulator debug decisions', function (): void { + $service = selfserve_wash_flow_without_constructor(); + + $debug = $service->buildStudioDebugPayload(6, [ + 'lane' => [ + 'id' => 7, + 'department' => 6, + 'name' => 'Lane 7', + 'relay_machine_id' => 'M-7', + 'relay_machine_program_picker_id' => 'PICKER-7', + ], + 'machine_type' => ['id' => 1001, 'name' => 'Portal'], + 'vehicle' => null, + 'reg' => 'TEST123', + 'customer_number' => null, + 'vehicle_type_id' => 4, + 'answers' => [], + 'questions' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Choose program', 'services' => ['PROGRAM_PICKER'], 'buttons' => [3], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 1], + ['id' => 42, 'task' => 'Press first button', 'services' => ['MACHINE'], 'buttons' => [0], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 2], + ], + 'allowed_services' => ['PROGRAM_PICKER', 'MACHINE'], + 'machine_available' => true, + 'all_visible_questions_answered' => true, + 'allowed' => true, + 'config_version_id' => 90, + 'config_source' => 'draft', + 'evaluation_trace' => [ + 'visible_question_ids' => [], + 'visibility_condition_results' => [], + 'condition_results' => [], + 'task_gates' => [ + ['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true], + ['task_id' => 42, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true], + ], + ], + 'debug_candidates' => [ + 'questions' => [], + 'conditions' => [], + 'rules' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Choose program', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['PROGRAM_PICKER'], 'buttons' => [3], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 1], + ['id' => 42, 'task' => 'Press first button', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'buttons' => [0], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 2], + ], + ], + ], [ + 'lookups' => [ + 'labels' => [ + 'departments' => ['6' => 'Roskilde'], + 'lanes' => ['7' => 'Lane 7'], + 'vehicle_types' => ['4' => 'Program 4'], + 'machine_types' => ['1001' => 'Portal'], + 'tasks' => ['41' => 'Choose program', '42' => 'Press first button'], + ], + ], + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 701, + 'label' => 'Roskilde Edge', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'PICKER-7', 'label' => 'Program picker relay', 'role' => 'PROGRAM_PICKER', 'services' => ['PROGRAM_PICKER']], + ['relay_id' => 'M-7', 'label' => 'Machine relay', 'role' => 'MACHINE', 'services' => ['MACHINE']], + ], + ], + ], + ], + ]); + + $dynamicImageDecisions = array_values(array_filter( + $debug['decisions'], + static fn(array $decision): bool => ($decision['kind'] ?? null) === 'dynamic_image_button' + )); + + expect(array_column($debug['dynamic_image_buttons'], 'button'))->toBe(['program_picker', 0]) + ->and(array_column($debug['dynamic_image_buttons'], 'label'))->toBe(['Program picker', '0']) + ->and(array_column($dynamicImageDecisions, 'label'))->toBe(['Program picker', '0']); +}); + +it('adds exact hidden question, skipped action, signal, and button decision causes', function (): void { + $service = selfserve_wash_flow_without_constructor(); + + $debug = $service->buildStudioDebugPayload(6, [ + 'lane' => [ + 'id' => 7, + 'department' => 6, + 'name' => 'Lane 7', + 'relay_machine_id' => 'M-7', + ], + 'machine_type' => ['id' => 1001, 'name' => 'Portal'], + 'vehicle' => null, + 'reg' => 'TEST123', + 'customer_number' => null, + 'vehicle_type_id' => 2, + 'answers' => [11 => false], + 'answer_sources' => [11 => 'override'], + 'questions' => [], + 'tasks' => [], + 'allowed_services' => [], + 'machine_available' => true, + 'all_visible_questions_answered' => true, + 'allowed' => false, + 'config_version_id' => 90, + 'config_source' => 'draft', + 'evaluation_trace' => [ + 'visible_question_ids' => [11], + 'visibility_condition_results' => [21 => false], + 'condition_results' => [21 => false], + 'visibility_expression_traces' => [ + 21 => [ + 'type' => 'condition', + 'condition_id' => 21, + 'result' => false, + 'expression' => [ + 'type' => 'group', + 'operator' => 'ALL', + 'result' => false, + 'children' => [ + [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 11, + 'operator' => 'IS_TRUE', + 'actual_value' => false, + 'result' => false, + 'reason' => 'Predicate did not pass.', + ], + ], + 'reason' => 'Group did not pass.', + ], + ], + ], + 'condition_expression_traces' => [ + 21 => [ + 'type' => 'condition', + 'condition_id' => 21, + 'result' => false, + 'expression' => [ + 'type' => 'predicate', + 'subject_type' => 'question', + 'subject_id' => 11, + 'operator' => 'IS_TRUE', + 'actual_value' => false, + 'result' => false, + 'reason' => 'Predicate did not pass.', + ], + ], + ], + 'task_gates' => [ + ['task_id' => 41, 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'satisfied' => false], + ], + ], + 'debug_candidates' => [ + 'questions' => [ + ['id' => 11, 'question' => 'Are mirrors folded?', 'condition_id' => null, 'order_priority' => 1], + ['id' => 12, 'question' => 'Is the lift lowered?', 'condition_id' => 21, 'order_priority' => 2], + ], + 'conditions' => [ + ['id' => 21, 'name' => 'Trailer present', 'condition_id' => null], + ], + 'rules' => [ + ['id' => 31, 'condition_id' => 21, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 11, 'name' => 'Mirror answer'], + ], + 'tasks' => [ + ['id' => 41, 'task' => 'Fold mirrors', 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'services' => ['MACHINE'], 'buttons' => ['start'], 'order_priority' => 1], + ], + 'actions' => [ + [ + 'id' => 81, + 'name' => 'Open entry on start', + 'event' => 'wash_start_command', + 'wash_mode' => 'both', + 'operation' => 'open_lane_entrance_port', + 'condition_id' => 21, + 'order_priority' => 1, + ], + ], + 'visible_answers' => [11 => false], + ], + ], [ + 'lookups' => [ + 'labels' => [ + 'departments' => ['6' => 'Roskilde'], + 'lanes' => ['7' => 'Lane 7'], + 'vehicle_types' => ['2' => 'Forvogn'], + 'machine_types' => ['1001' => 'Portal'], + 'questions' => ['11' => 'Are mirrors folded?', '12' => 'Is the lift lowered?'], + 'conditions' => ['21' => 'Trailer present'], + 'tasks' => ['41' => 'Fold mirrors'], + ], + ], + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 701, + 'label' => 'Roskilde Edge', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'M-7', 'label' => 'Machine relay', 'role' => 'MACHINE', 'services' => ['MACHINE']], + ], + ], + ], + ], + ]); + + $hiddenQuestion = array_values(array_filter($debug['questions'], static fn(array $question): bool => (int)$question['id'] === 12))[0] ?? []; + $taskDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'task'))[0] ?? []; + $actionDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'action'))[0] ?? []; + $signalDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'signal' && ($decision['state'] ?? '') === 'blocked'))[0] ?? []; + $buttonDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'dynamic_image_button'))[0] ?? []; + + expect($hiddenQuestion['state'])->toBe('hidden') + ->and($hiddenQuestion['reason'])->toBe('Question Is the lift lowered? hidden because visibility condition Trailer present expected true, actual false.') + ->and($debug['conditions'][0]['causes'][0])->toMatchArray([ + 'kind' => 'question', + 'id' => 11, + 'label' => 'Are mirrors folded?', + 'expected' => true, + 'actual' => false, + ]) + ->and($taskDecision['reason'])->toBe('Task Fold mirrors blocked because gate QUESTION Are mirrors folded? expected true, actual false.') + ->and($actionDecision['state'])->toBe('skipped') + ->and($actionDecision['causes'][0])->toMatchArray([ + 'kind' => 'condition', + 'id' => 21, + 'label' => 'Trailer present', + 'expected' => true, + 'actual' => false, + ]) + ->and($signalDecision['causes'][0]['reason'])->toContain('blocked') + ->and($buttonDecision['state'])->toBe('hidden') + ->and($buttonDecision['causes'][0]['label'])->toBe('Fold mirrors'); +}); + +it('projects visible question answer paths into grouped task service and signal outcomes', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $simulate = function (array $overrides): array { + $answers = []; + foreach ($overrides as $entry) { + $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null; + } + + $mirrorAnswer = $answers[11] ?? null; + $liftAnswer = $answers[12] ?? null; + $liftVisible = $mirrorAnswer === true; + $allowed = $mirrorAnswer === true && $liftAnswer === true; + + return [ + 'allowed' => $allowed, + 'questions' => array_values(array_filter([ + ['id' => 11, 'question' => 'Are mirrors folded?', 'answer' => $mirrorAnswer], + $liftVisible ? ['id' => 12, 'question' => 'Is the lift lowered?', 'answer' => $liftAnswer] : null, + ])), + 'tasks' => $allowed ? [ + ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => ['start']], + ] : [], + 'allowed_services' => $allowed ? ['MACHINE'] : [], + 'debug' => [ + 'questions' => [ + [ + 'id' => 11, + 'node_id' => 'question:11', + 'label' => 'Are mirrors folded?', + 'visible' => true, + 'answer' => $mirrorAnswer, + ], + [ + 'id' => 12, + 'node_id' => 'question:12', + 'label' => 'Is the lift lowered?', + 'visible' => $liftVisible, + 'answer' => $liftVisible ? $liftAnswer : null, + ], + ], + 'tasks' => [ + [ + 'id' => 41, + 'node_id' => 'task:41', + 'label' => 'Start machine', + 'active' => $allowed, + 'services' => ['MACHINE'], + 'buttons' => ['start'], + 'order_priority' => 1, + ], + ], + 'signal_timeline' => [ + [ + 'sequence' => 1, + 'runtime_stage' => 'eligibility_sync', + 'signal_type' => 'session_event', + 'relay_role' => 'SESSION', + 'source' => 'none', + 'predicted_status' => $allowed ? 'sent' : 'skipped', + 'payload' => ['allowed' => $allowed], + ], + [ + 'sequence' => 2, + 'runtime_stage' => 'machine_start_signal', + 'signal_type' => 'shelly_event', + 'relay_role' => 'MACHINE', + 'relay_id' => 'M-7', + 'target_binding' => 'binding:701:M-7:0', + 'target_gateway_label' => 'Roskilde Edge', + 'source' => 'real', + 'predicted_status' => $allowed ? 'sent' : 'skipped', + 'payload' => ['event' => 'input.toggle_on'], + ], + ], + ], + ]; + }; + + $projection = $service->projectPathOutcomesFromSimulator($simulate, [ + 'max_states' => 20, + 'scope' => [ + 'department_id' => 6, + 'lane_id' => 7, + 'vehicle_type_id' => 2, + 'config_source' => 'draft', + 'hardware_mode' => 'studio', + ], + ]); + + $allowedOutcome = array_values(array_filter( + $projection['outcomes'], + static fn(array $outcome): bool => ($outcome['allowed'] ?? false) === true + ))[0] ?? null; + $blockedOutcome = array_values(array_filter( + $projection['outcomes'], + static fn(array $outcome): bool => ($outcome['allowed'] ?? false) === false + ))[0] ?? null; + + expect($projection['truncated'])->toBeFalse() + ->and($projection['summary']['state_count'])->toBe(5) + ->and($projection['summary']['terminal_path_count'])->toBe(3) + ->and($projection['summary']['outcome_count'])->toBe(2) + ->and($projection['summary']['path_sample_count'])->toBe(3) + ->and($projection['summary']['question_ids'])->toBe([11, 12]) + ->and($projection['paths'])->toHaveCount(3) + ->and($allowedOutcome)->not->toBeNull() + ->and($allowedOutcome['path_count'])->toBe(1) + ->and($allowedOutcome['services'])->toBe(['MACHINE']) + ->and($allowedOutcome['tasks'][0]['label'])->toBe('Start machine') + ->and($allowedOutcome['signals'][1]['relay_role'])->toBe('MACHINE') + ->and($allowedOutcome['node_ids'])->toContain('binding:701:M-7:0') + ->and($blockedOutcome)->not->toBeNull() + ->and($blockedOutcome['path_count'])->toBe(2); + + $oneAnswerBlockedSample = array_values(array_filter( + $blockedOutcome['sample_chains'], + static fn(array $chain): bool => count((array)($chain['answers'] ?? [])) === 1 + ))[0] ?? null; + + expect($oneAnswerBlockedSample)->not->toBeNull() + ->and($oneAnswerBlockedSample['answers'][0]['question_id'])->toBe(11) + ->and($oneAnswerBlockedSample['answers'][0]['answer'])->toBeFalse(); + + $allowedPath = array_values(array_filter( + $projection['paths'], + static fn(array $path): bool => ($path['allowed'] ?? false) === true + ))[0] ?? null; + + expect($allowedPath)->not->toBeNull() + ->and($allowedPath['result'])->toBe('Allowed') + ->and($allowedPath['answers'])->toHaveCount(2) + ->and($allowedPath['answers'][0]['question'])->toBe('Are mirrors folded?') + ->and($allowedPath['services'])->toBe(['MACHINE']) + ->and($allowedPath['path_signature'])->not->toBe('') + ->and($allowedPath['result_signature'])->not->toBe('') + ->and($allowedPath['confirmation_status'])->toBe('unconfirmed') + ->and($allowedPath['node_ids'])->toContain('question:11') + ->and($allowedPath['node_ids'])->toContain('task:41'); +}); + +it('marks projected path confirmations confirmed or stale by stable signatures', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $simulate = static function (string $button): callable { + return static function (array $overrides) use ($button): array { + $answers = []; + foreach ($overrides as $entry) { + $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null; + } + $allowed = ($answers[11] ?? null) === true; + + return [ + 'allowed' => $allowed, + 'questions' => [ + ['id' => 11, 'question' => 'Machine wash is allowed', 'answer' => $answers[11] ?? null], + ], + 'tasks' => $allowed ? [ + ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => [$button]], + ] : [], + 'allowed_services' => $allowed ? ['MACHINE'] : [], + 'debug' => [ + 'questions' => [ + ['id' => 11, 'node_id' => 'question:11', 'label' => 'Machine wash is allowed', 'visible' => true, 'answer' => $answers[11] ?? null], + ], + 'tasks' => [ + ['id' => 41, 'node_id' => 'task:41', 'label' => 'Start machine', 'active' => $allowed, 'services' => ['MACHINE'], 'buttons' => [$button], 'order_priority' => 1], + ], + 'signal_timeline' => [], + ], + ]; + }; + }; + + $scope = [ + 'department_id' => 6, + 'lane_id' => 7, + 'vehicle_type_id' => 2, + 'config_source' => 'draft', + 'config_version_id' => 90, + 'hardware_mode' => 'studio', + ]; + $initial = $service->projectPathOutcomesFromSimulator($simulate('start'), ['scope' => $scope]); + $allowedPath = array_values(array_filter( + $initial['paths'], + static fn(array $path): bool => ($path['allowed'] ?? false) === true + ))[0] ?? []; + + $rows = [[ + 'path_signature' => $allowedPath['path_signature'], + 'result_signature' => $allowedPath['result_signature'], + 'answers' => $allowedPath['answers'], + 'result' => ['allowed' => true], + 'scope' => $scope, + 'confirmed_at' => '2026-05-27 10:00:00', + 'confirmed_by' => 9, + ]]; + $confirmed = $service->projectPathOutcomesFromSimulator($simulate('start'), [ + 'scope' => $scope, + 'confirmation_rows' => $rows, + ]); + $changed = $service->projectPathOutcomesFromSimulator($simulate('reset'), [ + 'scope' => $scope, + 'confirmation_rows' => $rows, + ]); + + $confirmedAllowed = array_values(array_filter( + $confirmed['paths'], + static fn(array $path): bool => ($path['allowed'] ?? false) === true + ))[0] ?? []; + $staleAllowed = array_values(array_filter( + $changed['paths'], + static fn(array $path): bool => ($path['allowed'] ?? false) === true + ))[0] ?? []; + + expect($confirmedAllowed['confirmation_status'])->toBe('confirmed') + ->and($confirmed['summary']['confirmations']['confirmed'])->toBe(1) + ->and($staleAllowed['confirmation_status'])->toBe('stale') + ->and($staleAllowed['stale_reason'])->toBe('Result changed since confirmation.') + ->and($changed['summary']['confirmations']['stale'])->toBe(1); +}); + +it('treats null path confirmation rows as an empty confirmation set', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $method = new ReflectionMethod(selfserve_studio_graph::class, 'pathOutcomesPayload'); + + $payload = $method->invoke( + $service, + ['department_id' => 6, 'lane_id' => 7], + [], + [], + [], + false, + null, + 0, + 0, + [], + [], + null + ); + + expect($payload['summary']['confirmations']['total'])->toBe(0) + ->and($payload['confirmations']['removed'])->toBe([]); +}); + +it('truncates path outcome projection when the state cap is reached', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $simulate = function (array $overrides): array { + $answers = []; + foreach ($overrides as $entry) { + $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null; + } + $first = $answers[1] ?? null; + $secondVisible = $first === true; + + return [ + 'allowed' => false, + 'questions' => [], + 'tasks' => [], + 'allowed_services' => [], + 'debug' => [ + 'questions' => [ + ['id' => 1, 'node_id' => 'question:1', 'label' => 'First', 'visible' => true, 'answer' => $first], + ['id' => 2, 'node_id' => 'question:2', 'label' => 'Second', 'visible' => $secondVisible, 'answer' => $answers[2] ?? null], + ], + 'tasks' => [], + 'signal_timeline' => [], + ], + ]; + }; + + $projection = $service->projectPathOutcomesFromSimulator($simulate, ['max_states' => 2]); + + expect($projection['truncated'])->toBeTrue() + ->and($projection['summary']['state_count'])->toBe(2) + ->and($projection['warnings'][0])->toContain('truncated at 2 explored state'); +}); + +it('returns complete terminal path results for wide question trees and reports progress', function (): void { + $service = selfserve_studio_graph_without_constructor(); + $simulate = function (array $overrides): array { + $answers = []; + foreach ($overrides as $entry) { + $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null; + } + + $questions = []; + foreach (range(1, 12) as $questionId) { + $questions[] = [ + 'id' => $questionId, + 'node_id' => 'question:' . $questionId, + 'label' => 'Question ' . $questionId, + 'visible' => true, + 'answer' => $answers[$questionId] ?? null, + ]; + } + + $complete = count($answers) === 12; + $allowed = $complete && !in_array(false, $answers, true); + + return [ + 'allowed' => $allowed, + 'questions' => [], + 'tasks' => $allowed ? [ + ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => ['start']], + ] : [], + 'allowed_services' => $allowed ? ['MACHINE'] : [], + 'debug' => [ + 'questions' => $questions, + 'tasks' => [ + [ + 'id' => 41, + 'node_id' => 'task:41', + 'label' => 'Start machine', + 'active' => $allowed, + 'services' => ['MACHINE'], + 'buttons' => ['start'], + 'order_priority' => 1, + ], + ], + 'signal_timeline' => [], + ], + ]; + }; + + $progressEvents = []; + $projection = $service->projectPathOutcomesFromSimulator($simulate, [ + 'progress_interval_states' => 512, + 'progress_callback' => static function (array $partial) use (&$progressEvents): void { + $progressEvents[] = [ + 'percent' => (int)($partial['progress']['percent'] ?? 0), + 'terminal_path_count' => (int)($partial['summary']['terminal_path_count'] ?? 0), + 'path_sample_count' => (int)($partial['summary']['path_sample_count'] ?? 0), + ]; + }, + ]); + + expect($projection['truncated'])->toBeFalse() + ->and($projection['summary']['state_count'])->toBe(8191) + ->and($projection['summary']['question_count'])->toBe(12) + ->and($projection['summary']['terminal_path_count'])->toBe(4096) + ->and($projection['summary']['outcome_count'])->toBe(2) + ->and($projection['summary']['path_sample_count'])->toBe(4096) + ->and($projection['progress']['complete'])->toBeTrue() + ->and($projection['progress']['percent'])->toBe(100) + ->and($projection['paths'])->toHaveCount(4096) + ->and($projection['paths'][0]['answers'])->toHaveCount(12) + ->and($projection['paths'][0]['result'])->toBe('Allowed') + ->and($progressEvents)->not->toBeEmpty() + ->and($progressEvents[0]['terminal_path_count'])->toBeGreaterThan(0) + ->and($progressEvents[0]['path_sample_count'])->toBeGreaterThan(0); +}); + +it('resolves simulator gateway service bindings from lane relay slots', function (): void { + $service = selfserve_wash_flow_without_constructor(); + + $debug = $service->buildStudioDebugPayload(6, [ + 'lane' => ['id' => 7, 'name' => 'Lane 7'], + 'machine_type' => ['id' => 1001, 'name' => 'Portal'], + 'vehicle' => null, + 'reg' => 'TEST123', + 'customer_number' => null, + 'vehicle_type_id' => 2, + 'answers' => [11 => true], + 'answer_sources' => [11 => 'override'], + 'questions' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE']], + ], + 'allowed_services' => ['MACHINE'], + 'machine_available' => true, + 'all_visible_questions_answered' => true, + 'allowed' => true, + 'config_version_id' => 90, + 'config_source' => 'draft', + 'evaluation_trace' => [ + 'visible_question_ids' => [11], + 'visibility_condition_results' => [], + 'condition_results' => [], + 'task_gates' => [ + ['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true], + ], + ], + 'debug_candidates' => [ + 'questions' => [ + ['id' => 11, 'question' => 'Are mirrors folded?', 'condition_id' => null, 'order_priority' => 1], + ], + 'conditions' => [], + 'rules' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'buttons' => [1], 'order_priority' => 1], + ], + ], + ], [ + 'lookups' => [ + 'labels' => [ + 'departments' => ['6' => 'Roskilde'], + 'lanes' => ['7' => 'Lane 7'], + 'vehicle_types' => ['2' => 'Forvogn'], + 'machine_types' => ['1001' => 'Portal'], + 'questions' => ['11' => 'Are mirrors folded?'], + 'tasks' => ['41' => 'Start machine'], + ], + ], + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 701, + 'label' => 'Roskilde Edge', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'M-7', 'label' => 'Machine relay'], + ], + ], + ], + 'lanes' => [ + [ + 'id' => 7, + 'relay_slots' => [ + ['relay_id' => 'M-7', 'slot' => 'MACHINE'], + ], + ], + ], + ], + 'graph' => [ + 'edges' => [ + ['id' => 'task-service:41:MACHINE:701:M-7:0', 'source' => 'task:41', 'target' => 'binding:701:M-7:0', 'label' => 'MACHINE'], + ], + ], + ]); + + expect($debug['hardware']['missing_service_bindings'])->toBe([]) + ->and($debug['hardware']['service_bindings']['MACHINE'][0]['node_id'])->toBe('binding:701:M-7:0') + ->and($debug['hardware']['summary'])->toBe('Lane relay and gateway service bindings are ready for the simulated services.') + ->and($debug['tasks'][0]['relay_bindings'][0]['service'])->toBe('MACHINE') + ->and(array_column($debug['recommendations'], 'title'))->toBe(['Flow is ready']); +}); + +it('generates and merges virtual hardware as studio-only relay coverage', function (): void { + $service = selfserve_virtual_hardware_without_constructor(); + $workspace = [ + 'gateways' => [], + 'relays' => [], + 'lanes' => [ + [ + 'id' => 7, + 'name' => 'Lane 7', + 'relay_slots' => [ + ['slot' => 'MACHINE', 'relay_id' => 'M-7', 'coverage' => ['covered' => false, 'status' => 'MISSING']], + ['slot' => 'EXIT', 'relay_id' => 'EXIT-7', 'coverage' => ['covered' => false, 'status' => 'MISSING']], + ], + 'binding_coverage' => ['required' => 2, 'bound' => 0, 'missing' => 2, 'state' => 'MISSING'], + ], + ], + 'issues' => [ + ['severity' => 'danger', 'code' => 'NO_GATEWAY', 'message' => 'No edge gateway has been claimed for this department.'], + ['severity' => 'warning', 'code' => 'LANE_BINDING_GAP', 'message' => 'Lane 7 is missing relay bindings.', 'target_type' => 'lane', 'target_id' => 7], + ], + 'actions' => [], + ]; + + $config = $service->generateFromLanes($workspace); + $merged = $service->mergeWorkspaceWithConfig($workspace, $config); + + expect($config['bindings'])->toHaveCount(2) + ->and($merged['virtual']['has_virtual_hardware'])->toBeTrue() + ->and($merged['gateways'][0]['virtual'])->toBeTrue() + ->and($merged['gateways'][0]['bindings'][0]['relay_id'])->toBe('M-7') + ->and($merged['lanes'][0]['binding_coverage']['state'])->toBe('READY') + ->and($merged['lanes'][0]['relay_slots'][0]['coverage']['virtual'])->toBeTrue() + ->and(array_column($merged['issues'], 'code'))->toContain('VIRTUAL_HARDWARE_ACTIVE') + ->and($service->validationWarnings($merged)[0])->toContain('live relay dispatch still requires a real edge gateway'); +}); + +it('renders virtual gateway nodes and task service edges in the studio graph', function (): void { + $virtual = selfserve_virtual_hardware_without_constructor(); + $graphService = selfserve_studio_graph_without_constructor(); + $workspace = $virtual->mergeWorkspaceWithConfig([ + 'gateways' => [], + 'relays' => [], + 'lanes' => [ + [ + 'id' => 7, + 'name' => 'Lane 7', + 'relay_slots' => [ + ['slot' => 'MACHINE', 'relay_id' => 'M-7', 'coverage' => ['covered' => false, 'status' => 'MISSING']], + ], + 'binding_coverage' => ['required' => 1, 'bound' => 0, 'missing' => 1, 'state' => 'MISSING'], + ], + ], + 'issues' => [], + 'actions' => [], + ], [ + 'schema_version' => 1, + 'enabled' => true, + 'gateways' => [['key' => 'virtual-main', 'label' => 'Virtual Studio Gateway', 'status' => 'VIRTUAL']], + 'relays' => [['relay_id' => 'M-7', 'name' => 'Lane 7 MACHINE']], + 'bindings' => [['gateway_key' => 'virtual-main', 'relay_id' => 'M-7', 'role' => 'MACHINE', 'services' => ['MACHINE'], 'label' => 'Lane 7 MACHINE']], + ]); + + $graph = $graphService->buildGraphFromConfig([ + 'questions' => [], + 'conditions' => [], + 'rules' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'order_priority' => 1], + ], + ], [ + 'lookups' => [ + 'labels' => [ + 'tasks' => ['41' => 'Start machine'], + 'lanes' => ['7' => 'Lane 7'], + ], + ], + 'gateway_workspace' => $workspace, + ]); + + $nodeIds = array_column($graph['nodes'], 'id'); + $edgeIds = array_column($graph['edges'], 'id'); + $gatewayNode = array_values(array_filter($graph['nodes'], static fn(array $node): bool => ($node['id'] ?? '') === 'gateway:virtual-main'))[0] ?? []; + $bindingNode = array_values(array_filter($graph['nodes'], static fn(array $node): bool => ($node['id'] ?? '') === 'binding:virtual-main:M-7:0'))[0] ?? []; + + expect($nodeIds)->toContain('gateway:virtual-main') + ->and($nodeIds)->toContain('binding:virtual-main:M-7:0') + ->and($edgeIds)->toContain('task-service:41:MACHINE:virtual-main:M-7:0') + ->and($gatewayNode['data']['raw']['virtual'])->toBeTrue() + ->and($bindingNode['data']['raw']['virtual'])->toBeTrue(); +}); + +it('inserts configured action signals into the simulator timeline in runtime order', function (): void { + $service = selfserve_wash_flow_without_constructor(); + + $debug = $service->buildStudioDebugPayload(6, [ + 'lane' => [ + 'id' => 7, + 'department' => 6, + 'name' => 'Lane 7', + 'relay_in_id' => 'ENTRY-7', + 'relay_out_id' => 'EXIT-7', + 'relay_machine_id' => 'M-7', + 'relay_machine_program_picker_id' => 'PICKER-7', + 'relay_machine_cleaner_id' => 'CLEAN-7', + ], + 'machine_type' => ['id' => 1001, 'name' => 'Portal'], + 'vehicle' => null, + 'reg' => 'TEST123', + 'customer_number' => null, + 'vehicle_type_id' => 2, + 'answers' => [], + 'answer_sources' => [], + 'questions' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE']], + ], + 'allowed_services' => ['MACHINE'], + 'machine_available' => true, + 'all_visible_questions_answered' => true, + 'allowed' => true, + 'config_version_id' => 90, + 'config_source' => 'draft', + 'evaluation_trace' => [ + 'visible_question_ids' => [], + 'visibility_condition_results' => [], + 'condition_results' => [21 => true], + 'task_gates' => [ + ['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true], + ], + ], + 'debug_candidates' => [ + 'questions' => [], + 'conditions' => [ + ['id' => 21, 'name' => 'Gate'], + ], + 'rules' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'order_priority' => 1], + ], + 'actions' => [ + [ + 'id' => 81, + 'name' => 'Open entry on start', + 'event' => 'wash_start_command', + 'wash_mode' => 'both', + 'operation' => 'open_lane_entrance_port', + 'condition_id' => 21, + 'order_priority' => 1, + 'options' => ['toggle_after_seconds' => 2], + ], + [ + 'id' => 82, + 'name' => 'Program picker off when machine starts', + 'event' => 'machine_start_triggered', + 'wash_mode' => 'machine', + 'operation' => 'set_program_picker_relay', + 'relay_state' => false, + 'order_priority' => 1, + ], + [ + 'id' => 83, + 'name' => 'Cleaner off on stop', + 'event' => 'wash_stop_command', + 'wash_mode' => 'machine', + 'operation' => 'set_cleaner_relay', + 'relay_state' => false, + 'order_priority' => 1, + ], + ], + ], + ], [ + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 701, + 'label' => 'Roskilde Edge', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'ENTRY-7', 'label' => 'Entry', 'role' => 'ENTRY', 'services' => ['ENTRY']], + ['relay_id' => 'M-7', 'label' => 'Machine', 'role' => 'MACHINE', 'services' => ['MACHINE']], + ['relay_id' => 'PICKER-7', 'label' => 'Program picker', 'role' => 'PROGRAM_PICKER', 'services' => ['PROGRAM_PICKER']], + ['relay_id' => 'CLEAN-7', 'label' => 'Cleaner', 'role' => 'CLEANER', 'services' => ['CLEANER']], + ['relay_id' => 'EXIT-7', 'label' => 'Exit', 'role' => 'EXIT', 'services' => ['EXIT']], + ], + ], + ], + 'lanes' => [ + [ + 'id' => 7, + 'relay_slots' => [ + ['slot' => 'ENTRY', 'relay_id' => 'ENTRY-7'], + ['slot' => 'MACHINE', 'relay_id' => 'M-7'], + ['slot' => 'PROGRAM_PICKER', 'relay_id' => 'PICKER-7'], + ['slot' => 'CLEANER', 'relay_id' => 'CLEAN-7'], + ['slot' => 'EXIT', 'relay_id' => 'EXIT-7'], + ], + ], + ], + ], + 'lookups' => [ + 'labels' => [ + 'lanes' => ['7' => 'Lane 7'], + 'conditions' => ['21' => 'Gate'], + 'tasks' => ['41' => 'Start machine'], + ], + ], + ]); + + expect(array_column($debug['signal_timeline'], 'sequence'))->toBe(range(1, 11)) + ->and(array_column($debug['signal_timeline'], 'relay_role'))->toBe([ + 'SESSION', + 'ENTRY', + 'MACHINE', + 'MACHINE', + 'PROGRAM_PICKER', + 'CLEANER', + 'CLEANER', + 'EXIT', + 'CLEANER', + 'MACHINE', + 'SESSION', + ]) + ->and($debug['signal_timeline'][1]['signal_type'])->toBe('studio_action_relay_pulse') + ->and($debug['signal_timeline'][1]['payload'])->toMatchArray(['action_id' => 81, 'id' => 'ENTRY-7', 'toggle_after' => 2]) + ->and($debug['signal_timeline'][4]['signal_type'])->toBe('studio_action_relay_switch') + ->and($debug['signal_timeline'][4]['payload'])->toMatchArray(['action_id' => 82, 'id' => 'PICKER-7', 'on' => false]) + ->and($debug['signal_timeline'][6]['payload'])->toMatchArray(['action_id' => 83, 'id' => 'CLEAN-7', 'on' => false]) + ->and($debug['actions'][0]['state'])->toBe('active') + ->and($debug['graph_annotations']['nodes']['action:81']['state'])->toBe('active'); +}); + +it('simulates lane-scoped wash start actions for property gates and lane entrance ports', function (): void { + $service = selfserve_wash_flow_without_constructor(); + + $debug = $service->buildStudioDebugPayload(6, [ + 'lane' => [ + 'id' => 7, + 'department' => 6, + 'name' => 'Lane 7', + 'relay_in_id' => 'ENTRY-7', + 'relay_out_id' => 'EXIT-7', + 'relay_machine_id' => 'M-7', + 'relay_machine_cleaner_id' => 'CLEAN-7', + ], + 'machine_type' => ['id' => 1001, 'name' => 'Portal'], + 'vehicle' => null, + 'reg' => 'TEST123', + 'customer_number' => 123, + 'vehicle_type_id' => 2, + 'answers' => [], + 'answer_sources' => [], + 'questions' => [], + 'tasks' => [['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE']]], + 'allowed_services' => ['MACHINE'], + 'machine_available' => true, + 'all_visible_questions_answered' => true, + 'allowed' => true, + 'config_version_id' => 90, + 'config_source' => 'draft', + 'evaluation_trace' => [ + 'visible_question_ids' => [], + 'visibility_condition_results' => [], + 'condition_results' => [], + 'task_gates' => [], + ], + 'debug_candidates' => [ + 'questions' => [], + 'conditions' => [], + 'rules' => [], + 'tasks' => [['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE']]], + 'actions' => [ + [ + 'id' => 80, + 'name' => 'Open property entrance on lane start', + 'event' => 'wash_start_command', + 'wash_mode' => 'both', + 'operation' => 'open_property_entrance_gate', + 'lane' => 7, + 'order_priority' => 1, + ], + [ + 'id' => 81, + 'name' => 'Open lane entrance on lane start', + 'event' => 'wash_start_command', + 'wash_mode' => 'both', + 'operation' => 'open_lane_entrance_port', + 'lane' => 7, + 'order_priority' => 2, + 'options' => ['toggle_after_seconds' => 3], + ], + ], + ], + ], [ + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 701, + 'label' => 'Roskilde Edge', + 'status' => 'ONLINE', + 'bindings' => [ + ['relay_id' => 'ENTRY-7', 'label' => 'Entry', 'role' => 'ENTRY', 'services' => ['ENTRY']], + ['relay_id' => 'M-7', 'label' => 'Machine', 'role' => 'MACHINE', 'services' => ['MACHINE']], + ['relay_id' => 'CLEAN-7', 'label' => 'Cleaner', 'role' => 'CLEANER', 'services' => ['CLEANER']], + ['relay_id' => 'EXIT-7', 'label' => 'Exit', 'role' => 'EXIT', 'services' => ['EXIT']], + ], + ], + ], + 'lanes' => [ + [ + 'id' => 7, + 'relay_slots' => [ + ['slot' => 'ENTRY', 'relay_id' => 'ENTRY-7'], + ['slot' => 'MACHINE', 'relay_id' => 'M-7'], + ['slot' => 'CLEANER', 'relay_id' => 'CLEAN-7'], + ['slot' => 'EXIT', 'relay_id' => 'EXIT-7'], + ], + ], + ], + ], + ]); + + $startSignals = array_values(array_filter( + $debug['signal_timeline'], + static fn(array $row): bool => str_starts_with((string)($row['signal_type'] ?? ''), 'studio_action_') + && ($row['payload']['event'] ?? null) === 'wash_start_command' + )); + + expect(array_column($startSignals, 'relay_role'))->toBe(['PROPERTY_ENTRANCE', 'ENTRY']) + ->and(array_column($startSignals, 'predicted_status'))->toBe(['sent', 'sent']) + ->and($startSignals[0]['payload'])->toMatchArray(['action_id' => 80, 'command' => 'OPEN_PROPERTY_ACCESS_GATE']) + ->and($startSignals[1]['payload'])->toMatchArray(['action_id' => 81, 'id' => 'ENTRY-7', 'toggle_after' => 3]); +}); + +it('adds ordered simulator signal timeline rows for virtual hardware dry runs', function (): void { + $service = selfserve_wash_flow_without_constructor(); + + $debug = $service->buildStudioDebugPayload(6, [ + 'lane' => ['id' => 7, 'name' => 'Lane 7'], + 'machine_type' => ['id' => 1001, 'name' => 'Portal'], + 'vehicle' => null, + 'reg' => 'TEST123', + 'customer_number' => null, + 'vehicle_type_id' => 2, + 'answers' => [], + 'answer_sources' => [], + 'questions' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE']], + ], + 'allowed_services' => ['MACHINE'], + 'machine_available' => true, + 'all_visible_questions_answered' => true, + 'allowed' => true, + 'config_version_id' => 90, + 'config_source' => 'draft', + 'evaluation_trace' => [ + 'visible_question_ids' => [], + 'visibility_condition_results' => [], + 'condition_results' => [], + 'task_gates' => [ + ['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true], + ], + ], + 'debug_candidates' => [ + 'questions' => [], + 'conditions' => [], + 'rules' => [], + 'tasks' => [ + ['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'order_priority' => 1], + ], + ], + ], [ + 'gateway_workspace' => [ + 'gateways' => [ + [ + 'id' => 'virtual-main', + 'label' => 'Virtual Studio Gateway', + 'status' => 'VIRTUAL', + 'virtual' => true, + 'bindings' => [ + ['node_id' => 'binding:virtual-main:M-7:0', 'relay_id' => 'M-7', 'label' => 'Machine', 'role' => 'MACHINE', 'services' => ['MACHINE'], 'virtual' => true], + ['node_id' => 'binding:virtual-main:CLEAN-7:1', 'relay_id' => 'CLEAN-7', 'label' => 'Cleaner', 'role' => 'CLEANER', 'services' => ['CLEANER'], 'virtual' => true], + ['node_id' => 'binding:virtual-main:EXIT-7:2', 'relay_id' => 'EXIT-7', 'label' => 'Exit', 'role' => 'EXIT', 'services' => ['EXIT'], 'virtual' => true], + ], + ], + ], + 'lanes' => [ + [ + 'id' => 7, + 'relay_slots' => [ + ['slot' => 'MACHINE', 'relay_id' => 'M-7'], + ['slot' => 'CLEANER', 'relay_id' => 'CLEAN-7'], + ['slot' => 'EXIT', 'relay_id' => 'EXIT-7'], + ], + ], + ], + 'virtual' => ['has_virtual_hardware' => true], + ], + 'lookups' => ['labels' => ['lanes' => ['7' => 'Lane 7'], 'tasks' => ['41' => 'Start machine']]], + ]); + + expect(array_column($debug['signal_timeline'], 'sequence'))->toBe([1, 2, 3, 4, 5, 6, 7, 8]) + ->and(array_column($debug['signal_timeline'], 'relay_role'))->toBe(['SESSION', 'MACHINE', 'MACHINE', 'CLEANER', 'EXIT', 'CLEANER', 'MACHINE', 'SESSION']) + ->and($debug['signal_timeline'][1]['predicted_status'])->toBe('virtual_only') + ->and($debug['signal_timeline'][2]['signal_type'])->toBe('shelly_event') + ->and($debug['signal_timeline'][2]['runtime_stage'])->toBe('machine_start_signal') + ->and($debug['signal_timeline'][2]['transport'])->toBe('shelly_webhook_or_edge_gateway_event') + ->and($debug['signal_timeline'][2]['payload'])->toMatchArray(['event' => 'input.toggle_on', 'bill_machine_wash' => true]) + ->and($debug['signal_timeline'][4]['payload'])->toMatchArray(['id' => 'EXIT-7', 'toggle_after' => 1]) + ->and($debug['hardware']['signal_timeline'][6]['payload'])->toMatchArray(['id' => 'M-7', 'on' => false]); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveWashCompletionRelayWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashCompletionRelayWiringTest.php index c4fdf8b5..ebd0f9f3 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveWashCompletionRelayWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashCompletionRelayWiringTest.php @@ -1,5 +1,79 @@ storedValue; + } +} + +class SelfserveWashCompletionDepartmentLaneFake extends \objects\department_lanes_o +{ + public function __construct(string $machineRelayId = 'relay-machine', string $cleanerRelayId = 'relay-cleaner') + { + $this->relay_machine_id = new SelfserveWashCompletionRelayValueFake($machineRelayId); + $this->relay_machine_cleaner_id = new SelfserveWashCompletionRelayValueFake($cleanerRelayId); + $this->relay_machine_program_picker_id = new SelfserveWashCompletionRelayValueFake(''); + } + + public function exists(): bool + { + return true; + } +} + +class SelfserveWashCompletionRelayLaneFake extends selfserve_lane +{ + /** @var selfserve_lane_relay[] */ + public array $statusReads = []; + /** @var array */ + public array $relayWrites = []; + + public function __construct(bool $reportedOn) + { + $this->id = 77; + $this->reportedOn = $reportedOn; + $this->department_lane = new SelfserveWashCompletionDepartmentLaneFake(); + } + + private bool $reportedOn; + + public function getRelayStatus(selfserve_lane_relay $relay): array + { + $this->statusReads[] = $relay; + return ['on' => $this->reportedOn]; + } + + public function setRelayStatusHard(selfserve_lane_relay $relay, bool $on): bool + { + $this->relayWrites[] = [$relay, $on]; + return true; + } +} + +class SelfserveWashCompletionFlowHarness extends selfserve_wash_flow +{ + public function __construct() {} + + public function turnOffConfiguredRelay(selfserve_lane $lane, selfserve_lane_relay $relay): void + { + $this->turnOffRelayIfConfigured($lane, $relay); + } +} + it('forces machine and cleaner relays off when a self-serve wash session is completed', function (): void { $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); @@ -10,14 +84,27 @@ it('forces machine and cleaner relays off when a self-serve wash session is comp expect($methodOffset)->not->toBeFalse(); $methodBody = substr($washFlow, (int)$methodOffset, 1500); - expect($methodBody)->toContain('$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE);'); - expect($methodBody)->toContain('$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE_CLEANER);'); + expect($methodBody)->toContain('$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE);'); + expect($methodBody)->toContain('$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE_CLEANER);'); - $helperOffset = strpos($washFlow, 'protected function turnOffRelayIfConfiguredAndOn'); + $helperOffset = strpos($washFlow, 'protected function turnOffRelayIfConfigured'); expect($helperOffset)->not->toBeFalse(); $helperBody = substr($washFlow, (int)$helperOffset, 1500); - expect($helperBody)->toContain('$status = $lane->getRelayStatus($relay);'); - expect($helperBody)->toContain("if ((bool)(\$status['on'] ?? false) !== true)"); expect($helperBody)->toContain('$lane->setRelayStatusHard($relay, false);'); + expect($helperBody)->not->toContain('$lane->getRelayStatus($relay)'); +}); + +it('always dispatches completion relay off for configured machine relays without a status precheck', function (): void { + $lane = new SelfserveWashCompletionRelayLaneFake(reportedOn: false); + $flow = new SelfserveWashCompletionFlowHarness(); + + $flow->turnOffConfiguredRelay($lane, selfserve_lane_relay::MACHINE); + $flow->turnOffConfiguredRelay($lane, selfserve_lane_relay::MACHINE_CLEANER); + + expect($lane->statusReads)->toBe([]) + ->and($lane->relayWrites)->toBe([ + [selfserve_lane_relay::MACHINE, false], + [selfserve_lane_relay::MACHINE_CLEANER, false], + ]); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveWashFlowMachineAllowedWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashFlowMachineAllowedWiringTest.php index 2b9608e4..51ad5fc3 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveWashFlowMachineAllowedWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashFlowMachineAllowedWiringTest.php @@ -1,5 +1,10 @@ not->toContain("return \$sessionSummary['allowed'] === true;"); }); +it('separates session synchronization from relay hardware synchronization', function (): void { + $washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php')); + $interface = file_get_contents(app_path('modules/selfserve/interfaces/selfserve_wash_flow_i.php')); + + expect($washFlow)->not->toBeFalse(); + expect($interface)->not->toBeFalse(); + expect($interface)->toContain('bool $syncRelayState = true'); + expect($washFlow)->toContain('bool $syncRelayState = true'); + expect($washFlow)->toContain('if ($syncRelayState) {'); + expect($washFlow)->toContain('$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);'); + expect($washFlow)->toContain('$this->synchronizeSession($laneId, $normalizedReg, null, false, null, false);'); +}); + +it('infers legacy-defaulted always task gates from condition_id at runtime', function (): void { + $reflection = new ReflectionClass(selfserve_wash_flow::class); + $flow = $reflection->newInstanceWithoutConstructor(); + $method = $reflection->getMethod('resolveTaskGate'); + $method->setAccessible(true); + + $conditionGate = $method->invoke($flow, [ + 'condition_id' => 22, + 'gate_type' => 'ALWAYS', + 'gate_ref_id' => null, + ], [22, 45]); + + expect($conditionGate['gate_type'])->toBe(selfserve_task_gate_type::CONDITION); + expect($conditionGate['gate_ref_id'])->toBe(22); + + $questionGate = $method->invoke($flow, [ + 'condition_id' => 11, + 'gate_type' => 'ALWAYS', + 'gate_ref_id' => null, + ], [22, 45]); + + expect($questionGate['gate_type'])->toBe(selfserve_task_gate_type::QUESTION); + expect($questionGate['gate_ref_id'])->toBe(11); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveWashSessionStateTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashSessionStateTest.php new file mode 100644 index 00000000..52730b56 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashSessionStateTest.php @@ -0,0 +1,47 @@ +set($value); + + return $property; +} + +function selfserve_session_harness( + string $status, + ?string $completedAt, + ?string $washStartedAt = '2026-04-28 10:00:00', + ?string $machineStartTriggeredAt = null +): selfserve_wash_sessions_o { + $session = (new ReflectionClass(selfserve_wash_sessions_o::class))->newInstanceWithoutConstructor(); + $session->status = selfserve_session_property('status', 'string', $status); + $session->completed_at = selfserve_session_property('completed_at', 'datetime', $completedAt); + $session->wash_started_at = selfserve_session_property('wash_started_at', 'datetime', $washStartedAt); + $session->machine_start_triggered_at = selfserve_session_property('machine_start_triggered_at', 'datetime', $machineStartTriggeredAt); + + return $session; +} + +it('treats terminal self-serve wash session statuses as closed', function (): void { + expect(selfserve_wash_sessions_o::isTerminalStatus('COMPLETED'))->toBeTrue() + ->and(selfserve_wash_sessions_o::isTerminalStatus('FORCE_STOPPED'))->toBeTrue() + ->and(selfserve_wash_sessions_o::isTerminalStatus('MACHINE_STARTED'))->toBeFalse() + ->and(selfserve_wash_sessions_o::terminalStatusSqlList())->toBe("'COMPLETED','FORCE_STOPPED'"); + + expect(selfserve_session_harness('MACHINE_STARTED', null)->isOpen())->toBeTrue() + ->and(selfserve_session_harness('COMPLETED', null)->isOpen())->toBeFalse() + ->and(selfserve_session_harness('MACHINE_STARTED', '2026-04-28 10:30:00')->isOpen())->toBeFalse(); +}); + +it('freezes elapsed self-serve wash minutes at completion time', function (): void { + expect(selfserve_session_harness('COMPLETED', '2026-04-28 10:42:00')->getElapsedMinutes())->toBe(42) + ->and(selfserve_session_harness('COMPLETED', '2026-04-28 10:35:00', null, '2026-04-28 10:05:00')->getElapsedMinutes())->toBe(30) + ->and(selfserve_session_harness('COMPLETED', '2026-04-28 09:59:00')->getElapsedMinutes())->toBe(0); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/ShellyRealRequestGuardTest.php b/services/nginx/app/tests/Unit/Selfserve/ShellyRealRequestGuardTest.php new file mode 100644 index 00000000..56bd28b9 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/ShellyRealRequestGuardTest.php @@ -0,0 +1,92 @@ + $client->sendPostRequest('/v2/devices/api/set/switch', [ + 'id' => 'real-relay', + 'on' => true, + ]))->toThrow(Exception::class, 'Real Shelly requests are blocked in test mode'); + + $entries = shelly::blockedRequestLog(); + expect($entries)->toHaveCount(1) + ->and($entries[0]['method'])->toBe('POST') + ->and($entries[0]['endpoint'])->toBe('/v2/devices/api/set/switch') + ->and($entries[0]['data'])->toMatchArray(['id' => 'real-relay', 'on' => true]); + + $logLines = file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + expect($logLines)->not->toBeFalse(); + $logged = json_decode((string)$logLines[0], true); + expect($logged)->toMatchArray([ + 'method' => 'POST', + 'endpoint' => '/v2/devices/api/set/switch', + ]); + } finally { + shelly::resetBlockedRequestLog(); + @unlink($logPath); + restore_shelly_guard_env_for_test('TRUCKWASH_TEST_BLOCK_REAL_SHELLY', $previousBlock); + restore_shelly_guard_env_for_test('TRUCKWASH_TEST_SHELLY_GUARD_LOG', $previousLog); + } +}); + +it('blocks and records test-mode Shelly GET requests before cURL can run', function (): void { + $previousBlock = getenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY'); + $previousLog = getenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG'); + $logPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'truckwash-shelly-guard-get-' . uniqid('', true) . '.jsonl'; + + try { + putenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY=1'); + putenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG=' . $logPath); + shelly::resetBlockedRequestLog(); + + $client = new ShellyRealRequestGuardHarness(); + + expect(fn() => $client->sendGetRequest('/device/all_status', [ + 'show_info' => 'true', + ]))->toThrow(Exception::class, 'Real Shelly requests are blocked in test mode'); + + $entries = shelly::blockedRequestLog(); + expect($entries)->toHaveCount(1) + ->and($entries[0]['method'])->toBe('GET') + ->and($entries[0]['endpoint'])->toBe('/device/all_status') + ->and($entries[0]['data'])->toMatchArray(['show_info' => 'true']); + } finally { + shelly::resetBlockedRequestLog(); + @unlink($logPath); + restore_shelly_guard_env_for_test('TRUCKWASH_TEST_BLOCK_REAL_SHELLY', $previousBlock); + restore_shelly_guard_env_for_test('TRUCKWASH_TEST_SHELLY_GUARD_LOG', $previousLog); + } +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/ZZZShellyGuardSafetyMetaTest.php b/services/nginx/app/tests/Unit/Selfserve/ZZZShellyGuardSafetyMetaTest.php new file mode 100644 index 00000000..3027bc3a --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/ZZZShellyGuardSafetyMetaTest.php @@ -0,0 +1,5 @@ +toBe([]); +}); diff --git a/services/nginx/app/tests/Unit/Subusers/SubuserGrantPermissionsTest.php b/services/nginx/app/tests/Unit/Subusers/SubuserGrantPermissionsTest.php new file mode 100644 index 00000000..198af4cc --- /dev/null +++ b/services/nginx/app/tests/Unit/Subusers/SubuserGrantPermissionsTest.php @@ -0,0 +1,25 @@ +toBeString(); + expect(json_decode($encoded, true)) + ->toContain('VEHICLES_LIST') + ->toContain('SELFSERVE_ADD') + ->toContain('BOOKINGS_LIST'); +}); + +it('normalizes stored subuser grant permission payloads', function (): void { + expect(subuser_grants_o::normalizePermissionsValue(0))->toBe([]); + expect(subuser_grants_o::normalizePermissionsValue('0'))->toBe([]); + expect(subuser_grants_o::normalizePermissionsValue('["vehicles_list","BOOKINGS_ADD"]')) + ->toBe(['VEHICLES_LIST', 'BOOKINGS_ADD']); + expect(subuser_grants_o::normalizePermissionsValue([ + 'VEHICLES_LIST' => true, + 'BOOKINGS_DELETE' => false, + 'UNKNOWN_PERMISSION' => true, + ]))->toBe(['VEHICLES_LIST']); +}); diff --git a/services/nginx/app/tests/Unit/Subusers/SubuserPasswordPolicyTest.php b/services/nginx/app/tests/Unit/Subusers/SubuserPasswordPolicyTest.php new file mode 100644 index 00000000..0e723bea --- /dev/null +++ b/services/nginx/app/tests/Unit/Subusers/SubuserPasswordPolicyTest.php @@ -0,0 +1,20 @@ +toBeTrue(); +}); + +it('rejects subuser passwords that do not match the shared policy', function (string $password): void { + expect(fn () => subusers_o::assertValidPassword($password)) + ->toThrow(Exception::class, 'Password must be between 8 and 255 characters long'); +})->with([ + 'too short' => ['Tes123'], + 'missing uppercase' => ['test1234'], + 'missing lowercase' => ['TEST1234'], + 'missing number' => ['TestPassword'], + 'too long' => [str_repeat('A', 256) . 'a1'], +]); diff --git a/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php b/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php index bb5d285e..21fd974f 100644 --- a/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php +++ b/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php @@ -9,6 +9,9 @@ it('exposes chauffeur management endpoints on the subusers route', function (): expect($normalized)->toContain("\$this->post('/subusers/invite', function () {"); expect($normalized)->toContain("\$this->post('/subusers/invite/resend', function () {"); + expect($normalized)->toContain("\$this->get('/superuser/subusers', function () {"); + expect($normalized)->toContain("\$this->post('/superuser/subusers/invite', function () {"); + expect($normalized)->toContain("\$this->post('/superuser/subusers/invite/resend', function () {"); expect($normalized)->toContain("\$this->put('/subusers', function () {"); expect($normalized)->toContain("\$this->put('/subusers/me', function () {"); }); @@ -31,6 +34,20 @@ it('includes grant management fields in the subusers payload builder', function expect($normalized)->toContain("'access_state' =>"); }); +it('resolves subuser customer names without external lookups during list requests', function (): void { + $routeFile = app_path('routes/subusersRoute.php'); + expect(is_file($routeFile))->toBeTrue(); + + $code = (string)file_get_contents($routeFile); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain('getCustomerNames($customerNumbers, false)'); + expect($normalized)->toContain('$customerName = $this->resolveCustomerName($customerNumber);'); + expect($normalized)->toContain('buildSubuserManagementPayload($subuser, $customerNumber, $customerName)'); + expect($normalized)->not->toContain("'customer_name' => (new users_o())->getCustomerName(\$customerNumber)"); + expect($normalized)->not->toContain("'name' => (new users_o())->getCustomerName((int)\$grant['billing_customer_number'])"); +}); + it('links grant disable operations to SUBUSERS_DELETE for own-customer managers', function (): void { $routeFile = app_path('routes/subusersRoute.php'); expect(is_file($routeFile))->toBeTrue(); @@ -52,6 +69,22 @@ it('prevents own-customer managers from editing driver-owned account profiles', expect($normalized)->toContain("Customers can only manage subuser grants. Drivers own their account profile."); }); +it('scopes superuser invite resend to a selected customer grant', function (): void { + $routeFile = app_path('routes/subusersRoute.php'); + expect(is_file($routeFile))->toBeTrue(); + + $code = (string)file_get_contents($routeFile); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain("\$this->post('/superuser/subusers/invite/resend', function () {"); + expect($normalized)->toContain("self::requireParameters(['id', 'customer_number']);"); + expect($normalized)->toContain("\$customerNumber = (int)self::getParameter('customer_number');"); + expect($normalized)->toContain("getGrantForSubuserAndCustomer(\$subuserId, \$customerNumber, true)"); + expect($normalized)->toContain("Subuser grant not found for selected customer"); + expect($normalized)->toContain("'subuser' => \$this->buildSubuserManagementPayload(\$subuser, \$customerNumber)"); + expect($normalized)->toContain("'grant' => \$grant->asArray()"); +}); + it('only allows invite resend while setup is still pending', function (): void { $routeFile = app_path('routes/subusersRoute.php'); expect(is_file($routeFile))->toBeTrue(); diff --git a/services/nginx/app/tests/Unit/Tooling/ComposerEntrypointTest.php b/services/nginx/app/tests/Unit/Tooling/ComposerEntrypointTest.php new file mode 100644 index 00000000..3362bad2 --- /dev/null +++ b/services/nginx/app/tests/Unit/Tooling/ComposerEntrypointTest.php @@ -0,0 +1,20 @@ +markTestSkipped('Docker entrypoint is only available inside the PHP container.'); + } + + $entrypoint = (string)file_get_contents($entrypointPath); + + expect($entrypoint)->toContain('http_message_sanity_ok') + ->and($entrypoint)->toContain('composer_lock_has_package "$dir" "psr/http-message"') + ->and($entrypoint)->toContain('UriInterface.php') + ->and($entrypoint)->toContain('StreamInterface.php') + ->and($entrypoint)->toContain('interface_exists("Psr\\\\Http\\\\Message\\\\UriInterface")') + ->and($entrypoint)->toContain('interface_exists("Psr\\\\Http\\\\Message\\\\StreamInterface")') + ->and($entrypoint)->toContain('if ! http_message_sanity_ok "$dir"; then'); +}); diff --git a/services/nginx/app/tests/Unit/Tooling/LegacyTestInventoryTest.php b/services/nginx/app/tests/Unit/Tooling/LegacyTestInventoryTest.php new file mode 100644 index 00000000..3dd6ef92 --- /dev/null +++ b/services/nginx/app/tests/Unit/Tooling/LegacyTestInventoryTest.php @@ -0,0 +1,38 @@ + str_replace('\\', '/', $entry['path']), + $manifest + ); + sort($manifestPaths); + + $testsRoot = app_path('tests'); + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($testsRoot, FilesystemIterator::SKIP_DOTS) + ); + + $actual = []; + foreach ($iterator as $file) { + if (!$file instanceof SplFileInfo || !$file->isFile()) { + continue; + } + + if (!str_ends_with($file->getFilename(), 'Test.php')) { + continue; + } + + $relative = str_replace('\\', '/', substr($file->getPathname(), strlen(app_path()) + 1)); + if (preg_match('#^tests/(Unit|Integration|Api|Legacy)/#', $relative) === 1) { + continue; + } + + $actual[] = $relative; + } + sort($actual); + + expect($actual)->toBe($manifestPaths); +}); diff --git a/services/nginx/app/tests/Unit/Tooling/MySqlSchemaCompatibilityTest.php b/services/nginx/app/tests/Unit/Tooling/MySqlSchemaCompatibilityTest.php new file mode 100644 index 00000000..0146aede --- /dev/null +++ b/services/nginx/app/tests/Unit/Tooling/MySqlSchemaCompatibilityTest.php @@ -0,0 +1,50 @@ +isFile() || $file->getExtension() !== 'php') { + continue; + } + + $path = str_replace('\\', '/', $file->getPathname()); + if (str_contains($path, '/vendor/')) { + continue; + } + + $contents = file_get_contents($file->getPathname()); + if ($contents !== false && preg_match($unsupportedPattern, $contents) === 1) { + $violations[] = str_replace('\\', '/', substr($file->getPathname(), strlen(app_path()) + 1)); + } + } + } + + sort($violations); + + expect($violations)->toBe( + [], + 'Avoid MariaDB-only schema syntax in CI-runner MySQL bootstraps: ' . implode(', ', $violations) + ); +}); diff --git a/services/nginx/app/tests/Unit/Users/UsersCustomerNamesCacheTest.php b/services/nginx/app/tests/Unit/Users/UsersCustomerNamesCacheTest.php index aae7ba21..1adb56bc 100644 --- a/services/nginx/app/tests/Unit/Users/UsersCustomerNamesCacheTest.php +++ b/services/nginx/app/tests/Unit/Users/UsersCustomerNamesCacheTest.php @@ -1,8 +1,10 @@ toBe(['name' => 'Truckwash ApS']); + expect(customer_name_cache_payload_builder::build( + '{"customer":{"name":"Nested Truckwash ApS"}}', + 'Fallback Name' + ))->toBe(['name' => 'Nested Truckwash ApS']); + + expect(customer_name_cache_payload_builder::build( + ['customer_name' => 'Array Truckwash ApS'], + 'Fallback Name' + ))->toBe(['name' => 'Array Truckwash ApS']); + expect(customer_name_cache_payload_builder::build( null, 'Fallback Name' @@ -27,8 +39,36 @@ it('guards bulk customer-name cache writes behind a resolved payload check', fun expect($content)->not->toBeFalse(); expect($content)->toContain('use classes\customer_name_cache_payload_builder;'); + expect($content)->toContain('use classes\system_search_economic_customer_index;'); expect($content)->toContain('return customer_name_cache_payload_builder::build($cached_name, $fallback_name);'); expect($content)->toContain('$cache_payload = self::buildCustomerNameCachePayload($cached_name, $fallback_name);'); expect($content)->toContain('if ($cache_payload !== null) {'); expect($content)->toContain("\$this->cache('economic_customer_name', \$cache_payload, \$customer_number);"); }); + +it('allows callers to resolve customer names without e-conomic fallback', function (): void { + $usersFile = app_path('objects/users_o.php'); + $content = file_get_contents($usersFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('public function getCustomerNames(array $customer_numbers, bool $allowExternalFetch = true): array'); + expect($content)->toContain('if (!$allowExternalFetch) {'); + expect($content)->toContain('$local_cached_names = $this->getCachedEconomicCustomerNamesByCustomerNumber($customer_numbers_to_fetch);'); + expect($content)->toContain("\$customer_names[(string)\$customer_number] = \$local_cached_names[\$customer_number] ?? \$fallback_names[\$customer_number] ?? 'Unknown Customer';"); + expect($content)->toContain('private function getLocalDisplayNamesByCustomerNumber(array $customer_numbers): array'); + expect($content)->toContain('private function getCachedEconomicCustomerNamesByCustomerNumber(array $customer_numbers): array'); + expect($content)->toContain("\$cached_names = \$this->getCachedForMultipleObjects('economic_customer', array_values(\$user_ids_by_customer_number));"); + expect($content)->toContain('system_search_economic_customer_index::TABLE'); +}); + +it('returns an empty customer name map without touching cache for empty input', function (): void { + $users = new users_o(); + + expect($users->getCustomerNames([]))->toBe([]); +}); + +it('returns no rows for empty array field filters', function (): void { + $users = new users_o(); + + expect($users->getFieldsWhere(['id' => []], ['customer_number']))->toBe([]); +}); diff --git a/services/nginx/app/tests/Unit/Users/UsersRedisNamespaceSafetyTest.php b/services/nginx/app/tests/Unit/Users/UsersRedisNamespaceSafetyTest.php new file mode 100644 index 00000000..7d4d5012 --- /dev/null +++ b/services/nginx/app/tests/Unit/Users/UsersRedisNamespaceSafetyTest.php @@ -0,0 +1,9 @@ +not->toContain('redis->') + ->and($content)->toContain("constant('redis')"); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherPreloadCronWiringTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherPreloadCronWiringTest.php index d8645dce..93826bad 100644 --- a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherPreloadCronWiringTest.php +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherPreloadCronWiringTest.php @@ -21,3 +21,29 @@ it('registers and implements cron preloading for department weather cache', func expect($routeContent)->toContain('withCachedDepartmentWeatherTimeline'); expect($routeContent)->toContain('getDepartmentWeatherCacheKey'); }); + +it('registers and implements cron warming for workfeed employee name cache', function (): void { + $cronContent = file_get_contents(app_path('cron/Cron.php')); + $routeContent = file_get_contents(app_path('routes/moduleWeatherAPIRoute.php')); + + expect($cronContent)->not->toBeFalse(); + expect($routeContent)->not->toBeFalse(); + + expect($cronContent)->toContain('WarmWorkfeedEmployeeNamesCron'); + expect($cronContent)->toContain("'function' => 'WarmWorkfeedEmployeeNamesCron'"); + expect($cronContent)->toContain("'interval' => 21600"); + expect($cronContent)->toContain('WORKFEED_EMPLOYEE_NAME_CACHE_TTL'); + expect($cronContent)->toContain('cache_workfeed_employee_name'); + expect($cronContent)->toContain('normalizeWorkfeedEmployeeWarmupCollection'); + expect($cronContent)->toContain('extractWorkfeedEmployeeWarmupIdentity'); + expect($cronContent)->toContain('extractWorkfeedEmployeeWarmupIds'); + expect($cronContent)->toContain('foreach ($employeeIds as $employeeId)'); + expect($cronContent)->toContain('workfeed_employee_name_formatter::fromRecord'); + expect($cronContent)->toContain("'firstname'"); + expect($cronContent)->toContain("'lastname'"); + expect($cronContent)->toContain("'employee.firstname'"); + expect($cronContent)->toContain("'employee.lastname'"); + + expect($routeContent)->toContain('fetchCachedWorkfeedEmployeeDisplayName'); + expect($routeContent)->toContain('get_workfeed_employee_name'); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTargetsRouteWiringTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTargetsRouteWiringTest.php index 59332422..811e9372 100644 --- a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTargetsRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTargetsRouteWiringTest.php @@ -11,6 +11,17 @@ it('registers department weather target routes with explicit read and manage per expect($content)->toContain("'department_access_:id'"); }); +it('registers department weather hour details route with weather-read and department access checks', function (): void { + $routeFile = app_path('routes/moduleWeatherAPIRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/departments/weather/hours/details'); + expect($content)->toContain("requirePermission('departments_weather_get')"); + expect($content)->toContain('parseDepartmentWeatherHourSlotFromRequest'); + expect($content)->toContain('loadDepartmentWeatherEmployeeHourDetailsByDepartment'); +}); + it('persists department weather targets using canonical department variable keys', function (): void { $routeFile = app_path('routes/moduleWeatherAPIRoute.php'); $content = file_get_contents($routeFile); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php index 94861641..2f1ec50e 100644 --- a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherWorkfeedHoursTest.php @@ -2,6 +2,8 @@ app_require('routes/moduleWeatherAPIRoute.php'); +use classes\workfeed; +use classes\redis; use routes\moduleWeatherAPIRoute; function weather_route_invoke_private(moduleWeatherAPIRoute $route, string $method, array $args = []): mixed @@ -23,22 +25,26 @@ it('calculates workfeed employee hours for the hour slot based on overlap', func $shifts = [ (object)[ 'departmentID' => 'dep_1', - 'start' => '2026-03-24T13:00:00+00:00', + 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], 'end' => '2026-03-24T14:00:00+00:00', ], (object)[ 'departmentID' => 'dep_1', - 'start' => '2026-03-24T13:30:00+00:00', + 'checkIn' => (object)['time' => '2026-03-24T13:30:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T15:00:00+00:00'], 'end' => '2026-03-24T15:00:00+00:00', ], (object)[ 'departmentID' => 'dep_other', - 'start' => '2026-03-24T13:00:00+00:00', + 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], 'end' => '2026-03-24T14:00:00+00:00', ], (object)[ 'departmentID' => 'dep_1', - 'start' => '2026-03-24T14:00:00+00:00', + 'checkIn' => (object)['time' => '2026-03-24T14:00:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T13:00:00+00:00'], 'end' => '2026-03-24T13:00:00+00:00', ], ]; @@ -48,6 +54,268 @@ it('calculates workfeed employee hours for the hour slot based on overlap', func expect($hours)->toBe(1.5); }); +it('calculates weather hour contributions grouped per employee for a slot', function (): void { + $route = new moduleWeatherAPIRoute(); + $slot = new DateTime('2026-03-24T13:00:00+00:00'); + + $shifts = [ + (object)[ + 'departmentID' => 'dep_1', + 'employeeID' => 'emp_1', + 'employeeName' => 'Alice', + 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], + 'end' => '2026-03-24T14:00:00+00:00', + ], + (object)[ + 'departmentID' => 'dep_1', + 'employeeID' => 'emp_1', + 'employeeName' => 'Alice', + 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T13:30:00+00:00'], + 'end' => '2026-03-24T13:30:00+00:00', + ], + (object)[ + 'departmentID' => 'dep_1', + 'employeeID' => 'emp_2', + 'employeeName' => 'Bob', + 'checkIn' => (object)['time' => '2026-03-24T13:15:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], + 'end' => '2026-03-24T14:00:00+00:00', + ], + (object)[ + 'departmentID' => 'dep_other', + 'employeeID' => 'emp_3', + 'employeeName' => 'Ignored', + 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], + 'end' => '2026-03-24T14:00:00+00:00', + ], + ]; + + $details = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHourByEmployee', [$shifts, 'dep_1', $slot]); + + expect($details)->toBe([ + [ + 'employee_id' => 'emp_1', + 'employee_name' => 'Alice', + 'hours' => 1.5, + ], + [ + 'employee_id' => 'emp_2', + 'employee_name' => 'Bob', + 'hours' => 0.75, + ], + ]); +}); + +it('calculates weather hour contributions for canonical nested workfeed employee schema', function (): void { + $route = new moduleWeatherAPIRoute(); + $slot = new DateTime('2026-03-24T13:00:00+00:00'); + + $shifts = [ + (object)[ + 'departmentID' => 'dep_1', + 'employee' => (object)[ + 'id' => '005FnnP0fHohybM1f3tx', + 'firstname' => 'Michael', + 'lastname' => 'Stenbæk Stampe', + ], + 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], + 'end' => '2026-03-24T14:00:00+00:00', + ], + ]; + + $details = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHourByEmployee', [$shifts, 'dep_1', $slot]); + + expect($details)->toBe([ + [ + 'employee_id' => '005FnnP0fHohybM1f3tx', + 'employee_name' => 'Michael Stenbæk Stampe', + 'hours' => 1.0, + ], + ]); +}); + +it('extracts weather employee identity from supported shift payload shapes', function (): void { + $route = new moduleWeatherAPIRoute(); + + $fromTopLevel = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ + (object)[ + 'employeeID' => 'emp_1', + 'employeeName' => 'Alice', + ], + ]); + + $fromNested = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ + (object)[ + 'employee' => (object)[ + 'id' => 'emp_2', + 'firstName' => 'Bob', + 'lastName' => 'Builder', + ], + ], + ]); + + $fromCanonicalNestedSchema = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ + (object)[ + 'employee' => (object)[ + 'id' => 'emp_3', + 'firstname' => 'Charlie', + 'lastname' => 'Day', + ], + ], + ]); + + expect($fromTopLevel)->toBe([ + 'id' => 'emp_1', + 'name' => 'Alice', + ]); + expect($fromNested)->toBe([ + 'id' => 'emp_2', + 'name' => 'Bob Builder', + ]); + expect($fromCanonicalNestedSchema)->toBe([ + 'id' => 'emp_3', + 'name' => 'Charlie Day', + ]); +}); + +it('prefers canonical workfeed employee schema fields over generic employee names', function (): void { + $route = new moduleWeatherAPIRoute(); + + $identity = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ + (object)[ + 'employee' => (object)[ + 'id' => 'emp_4', + 'firstname' => 'Dana', + 'lastname' => 'Scully', + 'name' => 'Wrong Name', + ], + 'employeeName' => 'Also Wrong', + ], + ]); + + expect($identity)->toBe([ + 'id' => 'emp_4', + 'name' => 'Dana Scully', + ]); +}); + +it('does not use workfeed schema name fields as employee ids', function (): void { + $route = new moduleWeatherAPIRoute(); + + $identity = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ + (object)[ + 'employee' => (object)[ + 'firstname' => 'Fox', + 'lastname' => 'Mulder', + ], + ], + ]); + + expect($identity)->toBe([ + 'id' => null, + 'name' => 'Fox Mulder', + ]); +}); + +it('returns a null employee name when no workfeed employee name is available', function (): void { + $route = new moduleWeatherAPIRoute(); + + $identity = weather_route_invoke_private($route, 'extractWorkfeedEmployeeIdentity', [ + (object)[ + 'employeeID' => 'emp_99', + ], + ]); + + expect($identity)->toBe([ + 'id' => 'emp_99', + 'name' => null, + ]); +}); + +it('resolves missing workfeed employee names from the employees endpoint', function (): void { + $route = new moduleWeatherAPIRoute(); + + $workfeed = new class extends workfeed { + public function __construct() + { + } + + public function listEmployees(array $filters = []): array|object + { + return [ + (object)[ + 'id' => 'emp_42', + 'firstname' => 'Jane', + 'lastname' => 'Doe', + ], + ]; + } + + public function getEmployee(string $id): object + { + return (object)[]; + } + }; + + $resolvedNames = []; + $employees = weather_route_invoke_private($route, 'resolveMissingWorkfeedEmployeeNames', [[ + [ + 'employee_id' => 'emp_42', + 'employee_name' => null, + 'hours' => 1.0, + ], + ], null, &$resolvedNames, $workfeed]); + + expect($employees)->toBe([[ + 'employee_id' => 'emp_42', + 'employee_name' => 'Jane Doe', + 'hours' => 1.0, + ]]); +}); + +it('filters employee hour rows that cannot be resolved to a workfeed schema name', function (): void { + $route = new moduleWeatherAPIRoute(); + + $resolvedNames = []; + $employees = weather_route_invoke_private($route, 'resolveMissingWorkfeedEmployeeNames', [[ + [ + 'employee_id' => 'emp_missing', + 'employee_name' => null, + 'hours' => 1.0, + ], + ], null, &$resolvedNames, null]); + + expect($employees)->toBe([]); +}); + +it('resolves employee display name from cache when shift payload lacks a name', function (): void { + $route = new moduleWeatherAPIRoute(); + + $cache = new class([ + 'emp_42' => 'Jane Doe', + ]) extends redis { + private array $namesByEmployeeId; + + public function __construct(array $namesByEmployeeId) + { + $this->namesByEmployeeId = $namesByEmployeeId; + } + + public function get_workfeed_employee_name(string $employeeId): string|null + { + return $this->namesByEmployeeId[$employeeId] ?? null; + } + }; + + $resolved_name = weather_route_invoke_private($route, 'fetchCachedWorkfeedEmployeeDisplayName', [$cache, 'emp_42']); + + expect($resolved_name)->toBe('Jane Doe'); +}); + it('extracts department id from supported workfeed shift shapes', function (): void { $route = new moduleWeatherAPIRoute(); @@ -93,12 +361,14 @@ it('calculates workfeed employee hours across multiple departments for one hour $shifts = [ (object)[ 'departmentID' => 'dep_1', - 'start' => '2026-03-24T13:00:00+00:00', + 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], 'end' => '2026-03-24T14:00:00+00:00', ], (object)[ 'departmentID' => 'dep_2', - 'start' => '2026-03-24T13:00:00+00:00', + 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'checkOut' => (object)['time' => '2026-03-24T14:00:00+00:00'], 'end' => '2026-03-24T14:00:00+00:00', ], ]; @@ -108,6 +378,44 @@ it('calculates workfeed employee hours across multiple departments for one hour expect($hours)->toBe(2.0); }); +it('does not count shifts without punches when checkIn/checkOut are null', function (): void { + $route = new moduleWeatherAPIRoute(); + $slot = new DateTime('2026-03-24T13:00:00+00:00'); + + $shifts = [ + (object)[ + 'departmentID' => 'dep_1', + 'checkIn' => null, + 'checkOut' => null, + 'start' => '2026-03-24T13:00:00+00:00', + 'end' => '2026-03-24T14:00:00+00:00', + ], + ]; + + $hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]); + + expect($hours)->toBe(0.0); +}); + +it('counts checked-in shifts without checkOut up to occurredUntil', function (): void { + $route = new moduleWeatherAPIRoute(); + $slot = new DateTime('2026-03-24T13:00:00+00:00'); + $occurredUntil = new DateTime('2026-03-24T13:40:00+00:00'); + + $shifts = [ + (object)[ + 'departmentID' => 'dep_1', + 'checkIn' => (object)['time' => '2026-03-24T13:10:00+00:00'], + 'checkOut' => null, + 'end' => '2026-03-24T16:00:00+00:00', + ], + ]; + + $hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot, $occurredUntil]); + + expect($hours)->toBe(0.5); +}); + it('counts overtime minutes when a saved shift end extends past the approved original end', function (): void { $route = new moduleWeatherAPIRoute(); $slot = new DateTime('2026-03-23T18:00:00+00:00'); @@ -195,7 +503,8 @@ it('caps current-slot hours to elapsed minutes and zeroes future slots', functio $shifts = [ (object)[ 'departmentID' => 'dep_1', - 'start' => '2026-03-24T13:00:00+00:00', + 'checkIn' => (object)['time' => '2026-03-24T13:00:00+00:00'], + 'checkOut' => null, 'end' => '2026-03-24T15:00:00+00:00', ], ]; diff --git a/services/nginx/app/tests/Unit/Workfeed/WorkfeedEmployeeNameFormatterTest.php b/services/nginx/app/tests/Unit/Workfeed/WorkfeedEmployeeNameFormatterTest.php new file mode 100644 index 00000000..46321e03 --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/WorkfeedEmployeeNameFormatterTest.php @@ -0,0 +1,86 @@ + 'employee_1', + 'firstname' => 'API 2', + 'lastname' => 'Test 2', + ]); + + expect($name)->toBe('API 2 Test 2'); +}); + +it('prefers workfeed schema names over generic display name fields', function (): void { + $name = workfeed_employee_name_from_record((object)[ + 'employee' => (object)[ + 'id' => 'employee_2', + 'firstname' => 'Jane', + 'lastname' => 'Doe', + 'name' => 'Wrong Name', + ], + 'employeeName' => 'Also Wrong', + ]); + + expect($name)->toBe('Jane Doe'); +}); + +it('falls back to legacy display name fields when schema names are absent', function (): void { + $name = workfeed_employee_name_from_record((object)[ + 'employeeID' => 'employee_3', + 'employeeName' => 'Legacy Name', + ]); + + expect($name)->toBe('Legacy Name'); +}); + +it('ignores placeholder workfeed display names', function (): void { + $name = workfeed_employee_name_from_record((object)[ + 'employeeID' => 'employee_4', + 'employeeName' => 'Unknown employee', + ]); + + expect($name)->toBeNull(); +}); + +it('ignores employee id placeholder display names when the id is known', function (): void { + $name = workfeed_employee_name_formatter::fromRecord((object)[ + 'employeeID' => 'employee_5', + 'employeeName' => 'Employee employee_5', + ], [ + 'firstname', + ], [ + 'lastname', + ], [ + 'employeeName', + ], 'employee_5'); + + expect($name)->toBeNull(); +}); diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php new file mode 100644 index 00000000..6a8ba391 --- /dev/null +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php @@ -0,0 +1,146 @@ +toBe('EC21233'); +}); + +it('builds stable XL Vask automation item signatures', function (): void { + $items = [ + ['product_id' => 20, 'quantity' => 1, 'price' => 275], + ['product_id' => 10, 'quantity' => 2, 'price' => 649], + ['product_id' => 20, 'quantity' => 1, 'price' => 0], + ]; + + expect(xlvask_automation_service::itemSignaturePartsForAutomation($items)) + ->toBe([ + '10:2:649', + '20:1:0', + '20:1:275', + ]); +}); + +it('normalizes persisted XL Vask usage-log rows before helper hydration', function (): void { + $row = xlvask_automation_service::normalizeUsageLogRowForAutomation([ + 'id' => 47086, + 'WashId' => 'cc1eabc1-b4e1-425b-ad7c-dc68f8c97ceb', + 'WashItems' => '[{"OriginalProductName":"Bus","Count":1}]', + ]); + + expect($row) + ->not->toHaveKey('id') + ->and($row['WashItems'])->toBe([ + [ + 'OriginalProductName' => 'Bus', + 'Count' => 1, + ], + ]); +}); + +it('builds stable OpenAI cache keys for identical automation input', function (): void { + $prompt = 'Prompt'; + $schemaName = 'xlvask_automation'; + $schema = [ + 'required' => ['action'], + 'properties' => [ + 'confidence' => ['type' => 'number'], + 'action' => ['type' => 'string'], + ], + ]; + $schemaWithDifferentKeyOrder = [ + 'properties' => [ + 'action' => ['type' => 'string'], + 'confidence' => ['type' => 'number'], + ], + 'required' => ['action'], + ]; + $payloadA = [ + 'usage_log' => [ + 'registration' => 'AB12345', + 'creation_allowed' => true, + ], + 'candidate_orders' => [ + ['id' => 10, 'items' => [['product_id' => 1, 'quantity' => 1, 'price' => 100]]], + ], + ]; + $payloadB = [ + 'candidate_orders' => [ + ['items' => [['price' => 100, 'quantity' => 1, 'product_id' => 1]], 'id' => 10], + ], + 'usage_log' => [ + 'creation_allowed' => true, + 'registration' => 'AB12345', + ], + ]; + + expect(xlvask_automation_service::openAiCacheKeyForAutomation($schemaName, $prompt, $payloadA, $schema, 0.1)) + ->toBe(xlvask_automation_service::openAiCacheKeyForAutomation($schemaName, $prompt, $payloadB, $schemaWithDifferentKeyOrder, 0.1)); +}); + +it('changes OpenAI cache keys when automation eligibility input changes', function (): void { + $schema = ['type' => 'object']; + $newerWashPayload = ['usage_log' => ['creation_allowed' => false, 'age_bucket' => 'newer_than_6_hours']]; + $olderWashPayload = ['usage_log' => ['creation_allowed' => true, 'age_bucket' => 'older_than_6_hours']]; + + expect(xlvask_automation_service::openAiCacheKeyForAutomation('xlvask_automation', 'Prompt', $newerWashPayload, $schema, 0.1)) + ->not->toBe(xlvask_automation_service::openAiCacheKeyForAutomation('xlvask_automation', 'Prompt', $olderWashPayload, $schema, 0.1)); +}); + +it('declares a persistent OpenAI cache table for XL Vask automation', function (): void { + $bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); + + expect($bootstrapContent) + ->toContain('xlvask_automation_openai_cache') + ->toContain('UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`)'); +}); + +it('declares cached amount summary columns for XL Vask usage logs', function (): void { + $bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); + + expect($bootstrapContent) + ->toContain('cached_total_net_amount') + ->toContain('cached_primary_product_name') + ->toContain('cached_amount_at'); +}); + +it('scores same-day orders with matching XL Vask products and extra add-ons as attach suggestions', function (): void { + $usageItems = [ + ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], + ['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']], + ['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']], + ['product_id' => 99, 'quantity' => 8, 'price' => 0, 'product' => ['name' => 'Halleje']], + ]; + $orderItems = [ + ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], + ['product_id' => 50, 'quantity' => 1, 'price' => 319, 'product' => ['name' => 'Indvendig vask Forvogn']], + ['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']], + ['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']], + ]; + + $score = xlvask_automation_service::scoreItemMatchForAutomation($usageItems, $orderItems); + + expect($score['source']) + ->toBe('fuzzy') + ->and($score['confidence'])->toBeGreaterThanOrEqual(0.70) + ->and($score['confidence'])->toBeLessThan(0.92) + ->and($score['reason'])->toContain('ekstra ydelser'); +}); + +it('does not score an order with only the primary product as a matching add-on attachment', function (): void { + $usageItems = [ + ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], + ['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']], + ['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']], + ]; + $orderItems = [ + ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], + ['product_id' => 50, 'quantity' => 1, 'price' => 319, 'product' => ['name' => 'Indvendig vask Forvogn']], + ]; + + expect(xlvask_automation_service::scoreItemMatchForAutomation($usageItems, $orderItems)['confidence']) + ->toBe(0.0); +}); diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageLogHelperTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageLogHelperTest.php new file mode 100644 index 00000000..69e642b8 --- /dev/null +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageLogHelperTest.php @@ -0,0 +1,59 @@ +setProperties([ + 'WashId' => 'wash-ignored-1', + 'CustomerId' => '35131752', + 'Customer' => 'BHS Logistics A/S', + 'VatNumber' => '35255156', + 'Location' => 'Aarhus C', + 'Hall' => 'AarhusC_1', + 'HallId' => 'hall-1', + 'StartTime' => '2026-05-11T08:23:23.000', + 'FinishTime' => '2026-05-11T08:31:23.000', + 'RegistrationNumber' => 'EX4451', + 'VehicleType' => 'Truck', + 'IdentificationType' => 'LPR', + 'IdentificationId' => 'EX4451', + 'Info' => 'EX4451', + 'Updated' => null, + 'Prepaid' => false, + 'FinishStatus' => 1, + 'CustomerGuid' => 'customer-guid-1', + 'VehicleId' => 'vehicle-id-1', + 'WashItems' => [], + 'ignored_at' => '2026-05-11 09:00:00', + 'ignored_by' => '42', + 'ignored_reason' => 'Already handled in period review', + ]); + + expect($log->ignored_at)->toBe('2026-05-11 09:00:00') + ->and($log->ignored_by)->toBe(42) + ->and($log->ignored_reason)->toBe('Already handled in period review'); +}); + +it('calculates XL Vask amount summaries without hydrating order item previews', function (): void { + $summary = xlvask_usage_logs_o::calculateAmountSummaryFromWashItems(json_encode([ + [ + 'OriginalProductName' => 'Stor bil', + 'PriceIncVat' => '625.00', + 'Vat' => '125.00', + ], + [ + 'OriginalProductName' => 'Skylning', + 'PriceIncVat' => '125,00', + 'Vat' => '25,00', + ], + ], JSON_THROW_ON_ERROR)); + + expect($summary) + ->toMatchArray([ + 'total_net_amount' => 600.0, + 'primary_product_name' => 'Stor bil', + ]); +}); diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php new file mode 100644 index 00000000..3c442a81 --- /dev/null +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php @@ -0,0 +1,45 @@ +not->toBeFalse(); + + $route = (string)$route; + + expect($route) + ->toContain('$linked_order_ids_by_wash_id = []') + ->and($route)->toContain('array_key_exists($wash_id, $linked_order_ids_by_wash_id)') + ->and($route)->toContain('selectByWashId($wash_id)') + ->and($route)->toContain("'linked_order_id' => \$linked_order_id") + ->and($route)->toContain("'usage_log_id' => \$id"); +}); + +it('does not execute XL Vask usage automation while listing usage order rows', function (): void { + $route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); + + expect($route)->not->toBeFalse(); + + $route = (string)$route; + + expect($route) + ->toContain('$automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, false);') + ->and($route)->not->toContain('$automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, true);') + ->and($route)->toContain("requirePermission('manage_xlvask_usage_automation')"); +}); + +it('returns cached amount summaries on XL Vask usage order rows without widening the usage-log object payload', function (): void { + $route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); + + expect($route)->not->toBeFalse(); + + $route = (string)$route; + + expect($route) + ->toContain('$amount_summary = $xlvask_usage_logs->getCachedAmountSummaryFromRow($log)') + ->and($route)->toContain('$usage_log_payload = array_intersect_key($log, array_flip([') + ->and($route)->toContain('$tmp->setProperties($usage_log_payload)') + ->and($route)->toContain("\$tmp_res['order']['total_net_amount'] = \$amount_summary['total_net_amount']") + ->and($route)->toContain("\$tmp_res['order']['xlvask_primary_product_name'] = \$amount_summary['primary_product_name']") + ->and($route)->toContain("\$tmp_res['order']['xlvask_amount_cached'] = \$amount_summary['cached']"); +}); diff --git a/services/nginx/app/tests/auth/RegisterCvrTest.php b/services/nginx/app/tests/auth/RegisterCvrTest.php index fdaae3a6..e3207736 100644 --- a/services/nginx/app/tests/auth/RegisterCvrTest.php +++ b/services/nginx/app/tests/auth/RegisterCvrTest.php @@ -101,7 +101,7 @@ namespace classes { }; } - public function createCustomer($number, $name, $cvr, $email, $phone): object + public function createCustomer($number, $name, $cvr, $email, $phone, $mobilePhone = null, $companyInformation = null): object { self::$create_calls[] = [ 'number' => (int)$number, @@ -109,6 +109,8 @@ namespace classes { 'cvr' => (string)$cvr, 'email' => (string)$email, 'phone' => (int)$phone, + 'mobile_phone' => $mobilePhone === null ? null : (int)$mobilePhone, + 'company_information' => $companyInformation, ]; $response = self::$mock_create_response ?? (object)[ @@ -127,11 +129,19 @@ namespace classes { class virkdata { public static string $mock_name = 'Mock Company'; + public static string $mock_address = 'Demo Street 1'; + public static int $mock_zipcode = 2630; + public static string $mock_city = 'Taastrup'; + public static string $mock_website = 'https://demo.test'; public function getCompanyInformation($cvr, $endpoint, $data): object { $result = new \stdClass(); $result->name = self::$mock_name; + $result->address = self::$mock_address; + $result->zipcode = self::$mock_zipcode; + $result->city = self::$mock_city; + $result->website = self::$mock_website; return $result; } @@ -426,7 +436,7 @@ namespace { ], [ 'name' => 'Successful registration bootstraps local user before welcome emails', - 'params' => $baseParams, + 'params' => array_merge($baseParams, ['contactPhone' => 87654320]), 'setup' => static function (): void { \classes\economic::$mock_create_response = (object)[ 'customerNumber' => 12345678, @@ -440,6 +450,14 @@ namespace { 'expected_status' => 201, 'assert' => static function (): void { assert_true(count(\classes\economic::$create_calls) === 1, 'Fresh registration must call create exactly once.'); + assert_true(\classes\economic::$create_calls[0]['phone'] === 12345678, 'Fresh registration must use the company phone as the e-conomic customer phone.'); + assert_true(\classes\economic::$create_calls[0]['mobile_phone'] === 87654320, 'Fresh registration must pass the contact phone as the e-conomic mobile phone.'); + $companyInformation = \classes\economic::$create_calls[0]['company_information']; + assert_true(is_object($companyInformation), 'Fresh registration must pass CVR company information to e-conomic.'); + assert_true($companyInformation->address === 'Demo Street 1', 'Fresh registration must pass the CVR address to e-conomic.'); + assert_true($companyInformation->zipcode === 2630, 'Fresh registration must pass the CVR zipcode to e-conomic.'); + assert_true($companyInformation->city === 'Taastrup', 'Fresh registration must pass the CVR city to e-conomic.'); + assert_true($companyInformation->website === 'https://demo.test', 'Fresh registration must pass the CVR website to e-conomic.'); assert_true(count(\classes\email::$sent) === 2, 'Fresh registration must send two welcome emails.'); assert_true(\objects\users_o::$interaction_log[0] === 'bootstrap:12345678', 'Fresh registration must bootstrap the local user before emails.'); assert_true(\classes\email::$sent[0]['email'] === 'jm@truckwash.dk', 'Fresh registration should notify the internal welcome recipient first.'); @@ -473,6 +491,10 @@ namespace { \classes\economic::reset(); \classes\email::reset(); \classes\virkdata::$mock_name = 'Mock Company'; + \classes\virkdata::$mock_address = 'Demo Street 1'; + \classes\virkdata::$mock_zipcode = 2630; + \classes\virkdata::$mock_city = 'Taastrup'; + \classes\virkdata::$mock_website = 'https://demo.test'; \objects\users_o::reset(); \objects\logs_o::reset(); diff --git a/services/nginx/app/tests/dynamicimages/DepartmentLanesImageTest.php b/services/nginx/app/tests/dynamicimages/DepartmentLanesImageTest.php index d8a97d93..23dd3478 100644 --- a/services/nginx/app/tests/dynamicimages/DepartmentLanesImageTest.php +++ b/services/nginx/app/tests/dynamicimages/DepartmentLanesImageTest.php @@ -30,13 +30,13 @@ namespace { // 1) Buttons normalization examples try { - $arr = department_selfserve_tasks_o::normalizeButtonsInput('0, 2,3 , 5'); - if ($arr === [0,2,3,5]) { ok('CSV buttons normalization works'); } else { fail('CSV normalization mismatch: '.json_encode($arr)); } + $arr = department_selfserve_tasks_o::normalizeButtonsInput('reset, 0, 2,3 , 5, start'); + if ($arr === ['reset',0,2,3,5,'start']) { ok('CSV mapped buttons normalization works'); } else { fail('CSV normalization mismatch: '.json_encode($arr)); } } catch (\Exception $e) { fail('CSV normalization threw: '.$e->getMessage()); } try { - $arr = department_selfserve_tasks_o::normalizeButtonsInput('[1, 1, "2", 4]'); - if ($arr === [1,2,4]) { ok('JSON buttons normalization with de-dup works'); } else { fail('JSON normalization mismatch: '.json_encode($arr)); } + $arr = department_selfserve_tasks_o::normalizeButtonsInput('[1, 1, "2", 4, "START"]'); + if ($arr === [1,2,4,'start']) { ok('JSON buttons normalization with de-dup works'); } else { fail('JSON normalization mismatch: '.json_encode($arr)); } } catch (\Exception $e) { fail('JSON normalization threw: '.$e->getMessage()); } // 2) Vehicle type normalization examples @@ -47,7 +47,7 @@ namespace { try { $img = new machine_1(); // Set some sample parameters (not used until setup(), which we skip to avoid Imagick requirement) - $img->highlighted_buttons = [0,2,5]; + $img->highlighted_buttons = ['reset',0,2,5,'start']; $img->current_step = 1; $dataUri = $img->exportAsBase64(); if (is_string($dataUri) && str_starts_with($dataUri, 'data:image/')) { diff --git a/services/nginx/app/tests/selfserve/ButtonsNormalizationTest.php b/services/nginx/app/tests/selfserve/ButtonsNormalizationTest.php index 158c7fc3..0dbbb7c2 100644 --- a/services/nginx/app/tests/selfserve/ButtonsNormalizationTest.php +++ b/services/nginx/app/tests/selfserve/ButtonsNormalizationTest.php @@ -53,11 +53,11 @@ namespace { fail('JSON input threw unexpectedly: ' . $e->getMessage()); } - // 3) CSV string input + // 3) CSV string input with mapped reset/start buttons try { - $result = department_selfserve_tasks_o::normalizeButtonsInput('6, 7 ,8'); - if ($result === [6,7,8]) { - ok('CSV input normalized correctly'); + $result = department_selfserve_tasks_o::normalizeButtonsInput('reset, 6, 7 ,8, start'); + if ($result === ['reset',6,7,8,'start']) { + ok('CSV input normalized mapped buttons correctly'); } else { fail('CSV input normalization mismatch: ' . json_encode($result)); } @@ -65,7 +65,19 @@ namespace { fail('CSV input threw unexpectedly: ' . $e->getMessage()); } - // 4) Invalid input should throw + // 4) JSON string input preserves reset/start and removes duplicates + try { + $result = department_selfserve_tasks_o::normalizeButtonsInput('["RESET", 1, "start", "reset"]'); + if ($result === ['reset',1,'start']) { + ok('JSON mapped buttons normalized with duplicates removed'); + } else { + fail('JSON mapped button normalization mismatch: ' . json_encode($result)); + } + } catch (\Exception $e) { + fail('JSON mapped button input threw unexpectedly: ' . $e->getMessage()); + } + + // 5) Invalid input should throw $thrown = false; try { department_selfserve_tasks_o::normalizeButtonsInput('["a", 2]'); diff --git a/services/nginx/app/tests/selfserve/StopTurnsOffRelayTest.php b/services/nginx/app/tests/selfserve/StopTurnsOffRelayTest.php index 0e689cb9..04d47bd2 100644 --- a/services/nginx/app/tests/selfserve/StopTurnsOffRelayTest.php +++ b/services/nginx/app/tests/selfserve/StopTurnsOffRelayTest.php @@ -11,10 +11,25 @@ namespace { namespace classes { class db { public function escape_string($s){ return (string)$s; } } class object_property { public function __construct($t=null,$i=null,$n='',$type='',$nullable=false){} public function value(){ return null; } } } namespace traits { trait db_object_t { protected string $table=''; protected int $id=0; public function setTable(string $t){ $this->table=$t; } public static function add_object(array $fields){ return 1; } public function select($id){ $this->id=(int)$id; return $this; } public function requireSelected(): void {} public function delete(): void {} } } -namespace objects { class department_lanes_o { public $department; public $relay_machine_id; public function __construct(int $departmentId = 0, string $relayId = ''){ $this->department = new \_ValueHolder($departmentId); $this->relay_machine_id = new \_ValueHolder($relayId); } public function exists(): bool { return true; } } } +namespace objects { class department_lanes_o { public $department; public $relay_machine_id; public $relay_machine_program_picker_id; public $relay_machine_cleaner_id; public function __construct(int $departmentId = 0, string $relayId = ''){ $this->department = new \_ValueHolder($departmentId); $this->relay_machine_id = new \_ValueHolder($relayId); $this->relay_machine_program_picker_id = new \_ValueHolder(''); $this->relay_machine_cleaner_id = new \_ValueHolder(''); } public function exists(): bool { return true; } } } + +namespace modules\selfserve\classes { + class selfserve_studio_actions { + public const EVENT_WASH_STOP_COMMAND = 'wash_stop_command'; + public const MODE_MACHINE = 'machine'; + public const MODE_MANUAL = 'manual'; + } + class selfserve_studio_action_runner { public function executeForLaneEvent(...$args): array { return []; } } + class selfserve_wash_flow { + public function hasMachineStartTriggeredForLane(...$args): bool { return true; } + public function completeLatestSessionForLane(...$args): void {} + } +} namespace { +require_once WD . '/traits/module_config_variable_t.php'; +require_once WD . '/traits/module_config_t.php'; require_once WD . '/modules/selfserve/classes/selfserve_lane.php'; require_once WD . '/modules/selfserve/helpers/selfserve_lane_command.php'; require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php'; @@ -58,9 +73,13 @@ class _TestLane extends selfserve_lane { // Stub out external effects protected function isDepartmentSelfServeEnabled(): bool { return true; } - public function open(selfserve_lane_port $port): bool { return true; } - public function invoice(): bool { return true; } + protected function runPublishedStudioActions(string $event, string $washMode, array $context = []): array { return []; } + protected function hasMachineStartSignalForStop(): bool { return true; } + protected function completeLatestSessionForStop(): void {} + public function open(selfserve_lane_port $port, ?int $toggle_after_seconds = null): bool { return true; } + public function invoice(?selfserve_lane_command_arguments $arguments = null): bool { return true; } public function logLaneAction(\modules\selfserve\helpers\selfserve_lane_log_action $action, int $status_code = 200, array $extra_data = []): void { /* no-op */ } + public function setRelayStatusHard(selfserve_lane_relay $relay, bool $on): bool { if ($relay === selfserve_lane_relay::MACHINE && $on === false) { $this->relayOffCalled = true; } return true; } public function turnOffRelay(selfserve_lane_relay $relay): bool { $this->relayOffCalled = true; return true; } } diff --git a/services/nginx/app/traits/db_object_t.php b/services/nginx/app/traits/db_object_t.php index a782d103..cf973549 100644 --- a/services/nginx/app/traits/db_object_t.php +++ b/services/nginx/app/traits/db_object_t.php @@ -173,6 +173,10 @@ trait db_object_t // If the value is "!null", add a where clause to check if the field is not null $where[] = "$field IS NOT NULL"; } elseif (is_array($value)) { + if (empty($value)) { + return []; + } + // If the value is an array, add a where clause to check if the field is in the array $in = implode(',', array_map(function ($v) { // Escape the value to prevent SQL injection @@ -1025,6 +1029,11 @@ trait db_object_t $this->table = $table; } + private static function dbObjectRedisCache(): ?object + { + return defined('redis') ? constant('redis') : null; + } + /** * Cache object * @param string $key The key to cache the object @@ -1041,7 +1050,7 @@ trait db_object_t $data = json_encode($data); } // Cache the data - redis->set($this->table . '_' . $objectId . '_' . $key, $data); + self::dbObjectRedisCache()?->set($this->table . '_' . $objectId . '_' . $key, $data); } /** @@ -1052,9 +1061,13 @@ trait db_object_t */ public function getCachedForMultipleObjects(string $key, array $objectIds): array { - return redis->mget(array_map(function($objectId) use ($key) { + if (empty($objectIds)) { + return []; + } + + return self::dbObjectRedisCache()?->mget(array_map(function($objectId) use ($key) { return $this->table . '_' . $objectId . '_' . $key; - }, $objectIds)); + }, $objectIds)) ?? []; } /** @@ -1073,7 +1086,7 @@ trait db_object_t $objectId = $this->id; } // Set the expiration time for the cached data - redis->expire($this->table . '_' . $objectId . '_' . $key, $seconds); + self::dbObjectRedisCache()?->expire($this->table . '_' . $objectId . '_' . $key, $seconds); } /** @@ -1105,7 +1118,7 @@ trait db_object_t $objectId = $this->id; } // Get the cached data - $data = redis->get($this->table . '_' . $objectId . '_' . $key) ?? null; + $data = self::dbObjectRedisCache()?->get($this->table . '_' . $objectId . '_' . $key) ?? null; // if the data is a JSON string, convert it to an array if (is_string($data) && json_decode($data)) { $data = json_decode($data); @@ -1156,8 +1169,11 @@ trait db_object_t if (!$objectId) { $objectId = $this->id; } + if (!defined('redis')) { + return; + } // Delete the cached data - redis->delete($this->table . '_' . $objectId . '_' . $key); + \constant('redis')->delete($this->table . '_' . $objectId . '_' . $key); } /** diff --git a/services/nginx/app/traits/economic_endpoint_t.php b/services/nginx/app/traits/economic_endpoint_t.php index 2d4cc8d6..2a2926e3 100644 --- a/services/nginx/app/traits/economic_endpoint_t.php +++ b/services/nginx/app/traits/economic_endpoint_t.php @@ -147,7 +147,8 @@ trait economic_endpoint_t CURLOPT_RETURNTRANSFER => true, //CURLOPT_ENCODING => '', CURLOPT_MAXREDIRS => 10, - CURLOPT_TIMEOUT => 0, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_TIMEOUT => 30, CURLOPT_FOLLOWLOCATION => true, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS, CURLOPT_CUSTOMREQUEST => $method, diff --git a/services/nginx/app/traits/module_config_t.php b/services/nginx/app/traits/module_config_t.php index 104d01b4..b36fbbcd 100644 --- a/services/nginx/app/traits/module_config_t.php +++ b/services/nginx/app/traits/module_config_t.php @@ -35,14 +35,33 @@ trait module_config_t */ function postConfigRequest(): bool { - // Check if the request has a variable name and value global $response; - if ($response->getRequestParameter('variable') === null || !$response->isRequestParameterSet('value')) { + $parameters = $response->getAllRequestParameters(); + + if (array_key_exists('variable', $parameters) || array_key_exists('value', $parameters)) { + if ($response->getRequestParameter('variable') === null || !$response->isRequestParameterSet('value')) { + $response->error('Variable and value not set', 400); + } + + $variable = $response->getRequestParameter('variable'); + $value = $response->getRequestParameter('value'); + return $this->setAllowedConfigVariable((string)$variable, $value); + } + + if ($parameters === []) { $response->error('Variable and value not set', 400); } - // Get the variable name and value - $variable = $response->getRequestParameter('variable'); - $value = $response->getRequestParameter('value'); + + foreach ($parameters as $variable => $value) { + $this->setAllowedConfigVariable((string)$variable, $value); + } + + return true; + } + + private function setAllowedConfigVariable(string $variable, mixed $value): bool + { + global $response; // Check if the variable is allowed to be updated if (!self::isVariableAllowed($variable)) { // Return an error message, telling the user that the variable is not allowed to be updated. With a list of allowed variables @@ -65,7 +84,6 @@ trait module_config_t } } - // Return the updated config variable return true; } diff --git a/services/nginx/nginx.conf b/services/nginx/nginx.conf index 672601ff..c10fe680 100644 --- a/services/nginx/nginx.conf +++ b/services/nginx/nginx.conf @@ -68,13 +68,13 @@ http { # Location block for PHP files location ^~ / { add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE"; - 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 ($request_method = 'OPTIONS') { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE"; - 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; } @@ -125,13 +125,13 @@ http { # Location block for PHP files location ^~ / { add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE"; - 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 ($request_method = 'OPTIONS') { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE"; - 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; } @@ -190,4 +190,4 @@ http { # Restrict access to the server, if the -} \ No newline at end of file +} diff --git a/services/nginx/nginx.dev.conf b/services/nginx/nginx.dev.conf index 258901bb..1608de11 100644 --- a/services/nginx/nginx.dev.conf +++ b/services/nginx/nginx.dev.conf @@ -50,12 +50,12 @@ http { # Main application location location / { add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE"; - 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 ($request_method = 'OPTIONS') { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, PUT, DELETE"; - 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; } diff --git a/services/php/Dockerfile b/services/php/Dockerfile index 2ad44be7..7498e5ed 100644 --- a/services/php/Dockerfile +++ b/services/php/Dockerfile @@ -33,6 +33,15 @@ RUN set -eux; \ ca-certificates \ mariadb-client; \ update-ca-certificates; \ + arch="$(dpkg --print-architecture)"; \ + case "$arch" in \ + amd64) mc_arch="amd64" ;; \ + arm64) mc_arch="arm64" ;; \ + *) echo "Unsupported architecture for MinIO Client: $arch" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://dl.min.io/client/mc/release/linux-${mc_arch}/mc" -o /usr/local/bin/mc; \ + chmod +x /usr/local/bin/mc; \ + mc --version; \ docker-php-ext-configure gd --with-freetype --with-jpeg; \ docker-php-ext-install -j"$(nproc)" \ mbstring \ @@ -64,7 +73,8 @@ WORKDIR /var/www/html # Copy and enable entrypoint that installs Composer deps on first run 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 # Expose port 9000 EXPOSE 9000 diff --git a/services/php/docker-entrypoint.sh b/services/php/docker-entrypoint.sh index b9af64f1..77ba94e5 100644 --- a/services/php/docker-entrypoint.sh +++ b/services/php/docker-entrypoint.sh @@ -36,6 +36,10 @@ vendor_sanity_ok() { return 1 fi + if ! http_message_sanity_ok "$dir"; then + return 1 + fi + if [ -f "$aws_s3_api_file" ] && ! php -l "$aws_s3_api_file" >/dev/null 2>&1; then log "Vendor sanity check failed for $aws_s3_api_file" return 1 @@ -44,6 +48,45 @@ vendor_sanity_ok() { return 0 } +composer_lock_has_package() { + dir="$1" + package="$2" + + if [ ! -f "$dir/composer.lock" ]; then + return 1 + fi + + grep -q "\"name\": \"$package\"" "$dir/composer.lock" +} + +http_message_sanity_ok() { + dir="$1" + autoload_file="$dir/vendor/autoload.php" + uri_file="$dir/vendor/psr/http-message/src/UriInterface.php" + stream_file="$dir/vendor/psr/http-message/src/StreamInterface.php" + + if ! composer_lock_has_package "$dir" "psr/http-message"; then + return 0 + fi + + if [ ! -f "$uri_file" ]; then + log "Vendor sanity check failed: missing $uri_file" + return 1 + fi + + if [ ! -f "$stream_file" ]; then + log "Vendor sanity check failed: missing $stream_file" + return 1 + fi + + if ! php -d display_errors=1 -r 'require $argv[1]; exit(interface_exists("Psr\\Http\\Message\\UriInterface") && interface_exists("Psr\\Http\\Message\\StreamInterface") ? 0 : 1);' "$autoload_file" >/dev/null 2>&1; then + log "Vendor sanity check failed: psr/http-message interfaces do not autoload in $dir" + return 1 + fi + + return 0 +} + wait_for_redis() { db_target="${CONFIG_DB_TARGET:-live}" if [ "$db_target" = "debug" ]; then @@ -130,6 +173,19 @@ install_if_needed() { fi } +refresh_root_autoload() { + if [ -f "$APP_DIR/composer.json" ] && [ -f "$APP_DIR/vendor/autoload.php" ]; then + log "Refreshing root Composer autoload after module dependency checks ..." + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + if ! COMPOSER_ALLOW_SUPERUSER=1 composer dump-autoload \ + --optimize --no-interaction \ + -d "$APP_DIR" 2>&1 | tee -a "$LOG_FILE"; then + log "ERROR: composer dump-autoload failed in $APP_DIR. See $LOG_FILE" + exit 1 + fi + fi +} + if [ "$AUTO_COMPOSER_INSTALL" = "true" ]; then if ! wait_for_file "$APP_DIR/composer.json" 120; then log "WARNING: $APP_DIR/composer.json not found after waiting - skipping auto-install" @@ -139,6 +195,7 @@ if [ "$AUTO_COMPOSER_INSTALL" = "true" ]; then if [ -f "$MODULE_DIR/composer.json" ]; then with_install_lock install_if_needed "$MODULE_DIR" + with_install_lock refresh_root_autoload fi else log "AUTO_COMPOSER_INSTALL=false - skipping Composer auto-install" diff --git a/services/traefik/dynamic.yml b/services/traefik/dynamic.yml index 77e6cf4c..ec86c6a9 100644 --- a/services/traefik/dynamic.yml +++ b/services/traefik/dynamic.yml @@ -28,7 +28,7 @@ http: - main: api.truckwash.dk api-preflight-io: - rule: (Host(`api.truckwash.io`) || Host(`localhost`)) && Method(`OPTIONS`) + rule: (Host(`api.truckwash.io`) || Host(`api-v2.truckwash.io`) || Host(`localhost`)) && Method(`OPTIONS`) entryPoints: [websecure, websecure-staging] middlewares: [secure-headers] service: noop@internal @@ -37,6 +37,7 @@ http: certResolver: le_io domains: - main: api.truckwash.io + - main: api-v2.truckwash.io cloud-preflight: rule: Host(`cloud.truckwash.dk`) && Method(`OPTIONS`) @@ -82,6 +83,7 @@ http: - "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" @@ -104,6 +106,11 @@ http: - Authorization - Content-Type - X-Customer-Number + - X-Release-Trace + - X-Release-Channel + - X-Frontend-Version + - Cache-Control + - Pragma api-ratelimit: rateLimit: average: 100 diff --git a/test/orderBookingsPost.http b/test/orderBookingsPost.http index 7b1e1850..b4f0d4f3 100644 --- a/test/orderBookingsPost.http +++ b/test/orderBookingsPost.http @@ -85,6 +85,13 @@ Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb206 "to_date": "2026-04-30", "closed_at": "2026-04-30" } + +### GET request to https://api.truckwash.io:4433/superuser/invoicing/period?dateFrom=2026-05-12&dateTo=2026-05-12&periodView=all&page=1&limit=100&search=&includeRequiresAction=1&includeBooked=1 +GET https://api.truckwash.io/superuser/invoicing/period?dateFrom=2026-04-01&dateTo=2026-04-30&periodView=all&page=1&limit=all&search=&includeRequiresAction=1&includeBooked=1 +Accept: application/json +Authorization: Bearer {{$API_TRUCKWASH_TOKEN}} +Content-Type: application/json + ### GET request to /subusers/setup GET https://api.truckwash.dk:4433/subusers/setup?token=1c1be8280bac3937487e5c77b76bb839 Accept: application/json