Merge origin/master and resolve property gate conflict

This commit is contained in:
copilot-swe-agent[bot]
2026-06-01 19:02:11 +00:00
committed by GitHub
296 changed files with 73986 additions and 4878 deletions
+3
View File
@@ -31,6 +31,9 @@ CONFIG_DB_DATABASE=
CONFIG_DB_PORT=3306
CONFIG_DB_SSL_MODE=DISABLED
# Browser origins allowed to call the API. Path-like entries are normalized to origins by the PHP CORS policy.
CORS=https://truckwash.io,https://www.truckwash.io,https://api.truckwash.io,https://api.truckwash.io:4433,https://api-v2.truckwash.io,https://web.truckwash.dk,https://api.truckwash.dk,https://truckwash.dk,https://www.truckwash.dk,https://staging.truckwash.io,http://localhost,https://localhost,http://localhost:4433,https://localhost:4433,https://twdev.jeppeb.dk,http://localhost:5173
# Debug DB credentials (used when CONFIG_DB_TARGET=debug)
# Any blank debug value falls back to the live value above.
CONFIG_DB_DEBUG_HOST=mysql-debug
+15
View File
@@ -0,0 +1,15 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.pdf binary
*.zip binary
*.webm binary
+48
View File
@@ -0,0 +1,48 @@
USE_ENV=true
DEBUG=true
ENCRYPTION_KEY=ci-test-encryption-key
CORS=*
CONFIG_TIMEZONE=Europe/Copenhagen
CONFIG_DB_TARGET=debug
CONFIG_DB_HOST=mysql-debug
CONFIG_DB_USER=root
CONFIG_DB_PASSWORD=debug_root_password
CONFIG_DB_DATABASE=nnks_db_debug
CONFIG_DB_PORT=3306
CONFIG_DB_SSL_MODE=DISABLED
CONFIG_DB_DEBUG_HOST=mysql-debug
CONFIG_DB_DEBUG_USER=root
CONFIG_DB_DEBUG_PASSWORD=debug_root_password
CONFIG_DB_DEBUG_DATABASE=nnks_db_debug
CONFIG_DB_DEBUG_PORT=3306
CONFIG_DB_DEBUG_SSL_MODE=DISABLED
REDIS_CONFIG_HOST=redis
REDIS_CONFIG_USER=default
REDIS_CONFIG_DATABASE=0
REDIS_CONFIG_PASSWORD=
REDIS_CONFIG_PORT=6379
REDIS_CONFIG_DEBUG_HOST=redis
REDIS_CONFIG_DEBUG_USER=default
REDIS_CONFIG_DEBUG_DATABASE=0
REDIS_CONFIG_DEBUG_PASSWORD=
REDIS_CONFIG_DEBUG_PORT=6379
ECONOMIC_API_APP_ACCESS_GRANT=ci-test
ECONOMIC_API_APP_ACCESS_GRANT2=ci-test-secondary
ECONOMIC_API_APP_SECRET_TOKEN=ci-test-secret
WORDPRESS_STATIC_TOKEN=ci-test
EMAIL_WASH_CERTIFICATE_TOKEN=ci-test
WORDPRESS_API_URL=http://localhost
MINIO_ENDPOINT=
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
SLACK_DEFAULT_WEBHOOK=
EDGE_BROKER_URL=http://edge-broker:4300
EDGE_PUBLIC_BROKER_URL=http://localhost/api/edge-broker
EDGE_AUTH_MODE=manager
EDGE_BROKER_SHARED_SECRET=truckwash-edge-ci
EDGE_GATEWAY_VIEW_CACHE_TTL=0
TRUCKWASH_TEST_BLOCK_REAL_SHELLY=1
+48
View File
@@ -0,0 +1,48 @@
USE_ENV=true
DEBUG=true
ENCRYPTION_KEY=ci-test-encryption-key
CORS=*
CONFIG_TIMEZONE=Europe/Copenhagen
CONFIG_DB_TARGET=debug
CONFIG_DB_HOST=mysql-debug
CONFIG_DB_USER=root
CONFIG_DB_PASSWORD=debug_root_password
CONFIG_DB_DATABASE=nnks_db_debug
CONFIG_DB_PORT=3306
CONFIG_DB_SSL_MODE=DISABLED
CONFIG_DB_DEBUG_HOST=mysql-debug
CONFIG_DB_DEBUG_USER=root
CONFIG_DB_DEBUG_PASSWORD=debug_root_password
CONFIG_DB_DEBUG_DATABASE=nnks_db_debug
CONFIG_DB_DEBUG_PORT=3306
CONFIG_DB_DEBUG_SSL_MODE=DISABLED
REDIS_CONFIG_HOST=redis
REDIS_CONFIG_USER=default
REDIS_CONFIG_DATABASE=0
REDIS_CONFIG_PASSWORD=
REDIS_CONFIG_PORT=6379
REDIS_CONFIG_DEBUG_HOST=redis
REDIS_CONFIG_DEBUG_USER=default
REDIS_CONFIG_DEBUG_DATABASE=0
REDIS_CONFIG_DEBUG_PASSWORD=
REDIS_CONFIG_DEBUG_PORT=6379
ECONOMIC_API_APP_ACCESS_GRANT=ci-test
ECONOMIC_API_APP_ACCESS_GRANT2=ci-test-secondary
ECONOMIC_API_APP_SECRET_TOKEN=ci-test-secret
WORDPRESS_STATIC_TOKEN=ci-test
EMAIL_WASH_CERTIFICATE_TOKEN=ci-test
WORDPRESS_API_URL=http://localhost
MINIO_ENDPOINT=
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
SLACK_DEFAULT_WEBHOOK=
EDGE_BROKER_URL=http://edge-broker:4300
EDGE_PUBLIC_BROKER_URL=http://localhost/api/edge-broker
EDGE_AUTH_MODE=manager
EDGE_BROKER_SHARED_SECRET=truckwash-edge-ci
EDGE_GATEWAY_VIEW_CACHE_TTL=0
TRUCKWASH_TEST_BLOCK_REAL_SHELLY=1
+39
View File
@@ -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
+19 -1
View File
@@ -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."
+107 -152
View File
@@ -5,69 +5,42 @@ on:
push:
jobs:
unit:
name: Unit (required)
# Match the labels exposed by the Coolify-managed GitHub runner.
php:
name: PHP ${{ matrix.suite }} (required)
runs-on: [self-hosted, Linux, X64, default]
strategy:
fail-fast: false
matrix:
suite: [unit, integration, api, legacy]
env:
COMPOSE_PROJECT_NAME: php-${{ github.run_id }}-${{ github.job }}-${{ matrix.suite }}-${{ github.run_attempt }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
if: ${{ matrix.suite == 'unit' }}
uses: actions/setup-node@v4
with:
node-version: 22
- name: Check AI workflow sync
if: ${{ matrix.suite == 'unit' }}
run: node scripts/sync-ai-workflow.mjs --check
- name: Materialize compose env files
env:
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
run: |
set -euo pipefail
if [ -z "${COMPOSE_ENV}" ]; then
echo "Required GitHub secret COMPOSE_ENV is not configured." >&2
exit 1
fi
if [ -z "${COMPOSE_ENV_STAGING}" ]; then
echo "Required GitHub secret COMPOSE_ENV_STAGING is not configured." >&2
exit 1
fi
printf '%s\n' "$COMPOSE_ENV" > .env
printf '%s\n' "$COMPOSE_ENV_STAGING" > .env.staging
- name: Run PHP ${{ matrix.suite }} suite
run: bash scripts/php-ci-test.sh ${{ matrix.suite }}
- name: Boot php1 test stack
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d redis mysql-debug php1
- name: Sync PHP app checkout
run: tar -C services/nginx/app -cf - . | docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
- name: Resolve dependencies
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer update --no-interaction --prefer-dist"
- name: Run unit tests
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer test:unit"
- name: Generate coverage report
run: |
docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && if php -m | grep -Eq '^(pcov|xdebug)$'; then composer test:coverage; else echo 'Skipping coverage: no pcov/xdebug extension available in php1 image.'; fi"
- name: Upload coverage artifact
if: ${{ github.event_name == 'pull_request' }}
- name: Upload PHP suite logs
if: ${{ failure() }}
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: unit-coverage-clover
path: services/nginx/app/build/logs/clover.xml
name: php-${{ matrix.suite }}-logs
path: .tmp/ci-logs/${{ matrix.suite }}
if-no-files-found: warn
retention-days: 1
- name: Tear down php1 test stack
if: always()
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
retention-days: 3
edge-agent:
name: Edge Agent (required)
@@ -84,6 +57,30 @@ jobs:
cache: npm
cache-dependency-path: services/edge-agent/package-lock.json
- name: Install native build tools
run: |
set -euo pipefail
if command -v make >/dev/null 2>&1 && command -v g++ >/dev/null 2>&1; then
exit 0
fi
if ! command -v apt-get >/dev/null 2>&1; then
echo "make and g++ are required to install node-pty, but apt-get is not available on this runner." >&2
exit 1
fi
apt_cmd=(apt-get)
if [ "$(id -u)" -ne 0 ]; then
if ! command -v sudo >/dev/null 2>&1; then
echo "make and g++ are missing, and sudo is not available to install them." >&2
exit 1
fi
apt_cmd=(sudo apt-get)
fi
"${apt_cmd[@]}" update
"${apt_cmd[@]}" install -y --no-install-recommends build-essential python3
- name: Install dependencies
working-directory: services/edge-agent
run: npm ci
@@ -100,22 +97,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Materialize compose env files
env:
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
- name: Materialize CI compose env files
run: |
set -euo pipefail
if [ -z "${COMPOSE_ENV}" ]; then
echo "Required GitHub secret COMPOSE_ENV is not configured." >&2
exit 1
fi
if [ -z "${COMPOSE_ENV_STAGING}" ]; then
echo "Required GitHub secret COMPOSE_ENV_STAGING is not configured." >&2
exit 1
fi
printf '%s\n' "$COMPOSE_ENV" > .env
printf '%s\n' "$COMPOSE_ENV_STAGING" > .env.staging
cp .github/ci.env .env
cp .github/ci.env.staging .env.staging
- name: Validate compose contracts
run: |
@@ -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 }}
+2
View File
@@ -12,3 +12,5 @@
/services/caddy/logs*
/.tmp/
/.env.staging
/services/nginx/app/storage/replication-bootstrap.json
+3 -2
View File
@@ -46,7 +46,8 @@ COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
# Runtime bootstrap: lightweight entrypoint to ensure Composer deps exist when app is bind-mounted
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
&& chmod +x /usr/local/bin/docker-entrypoint.sh
# Install PHP dependencies through Composer (only where composer.json exists)
# Main app dependencies
@@ -72,4 +73,4 @@ EXPOSE 80 443
ENTRYPOINT ["docker-entrypoint.sh"]
# Start services when no command is provided (docker-compose overrides this with ["php-fpm"])
CMD ["php-fpm"]
CMD ["php-fpm"]
+73
View File
@@ -0,0 +1,73 @@
FROM php:8.2.15-fpm
WORKDIR /var/www/html
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
$PHPIZE_DEPS \
ca-certificates \
curl \
default-mysql-client \
git \
imagemagick \
libfreetype6-dev \
libjpeg62-turbo-dev \
libmagickcore-dev \
libmagickwand-dev \
libonig-dev \
libpng-dev \
libssl-dev \
libxml2-dev \
libzip-dev \
mariadb-client \
nginx \
pkg-config \
redis-tools \
unzip \
zip; \
update-ca-certificates; \
docker-php-ext-configure gd --with-freetype --with-jpeg; \
docker-php-ext-install -j"$(nproc)" \
bcmath \
exif \
gd \
mbstring \
mysqli \
pcntl \
pdo_mysql \
sockets \
zip; \
pecl install imagick-3.7.0 redis; \
docker-php-ext-enable imagick redis; \
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false $PHPIZE_DEPS; \
rm -rf /var/lib/apt/lists/*
COPY services/nginx/app/ /var/www/html/
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
RUN set -eux; \
sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \
chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction -d /var/www/html; \
if [ -f /var/www/html/modules/washcertificates/composer.json ]; then \
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction -d /var/www/html/modules/washcertificates; \
fi; \
COMPOSER_ALLOW_SUPERUSER=1 composer dump-autoload --no-dev --optimize --no-interaction -d /var/www/html; \
php -d display_errors=1 -r 'require "/var/www/html/vendor/autoload.php"; exit(interface_exists("Psr\\Http\\Message\\UriInterface") && interface_exists("Psr\\Http\\Message\\StreamInterface") ? 0 : 1);'; \
chown -R www-data:www-data /var/www/html; \
chmod -R 755 /var/www/html
ENV APP_DIR=/var/www/html \
MODULE_DIR=/var/www/html/modules/washcertificates \
AUTO_COMPOSER_INSTALL=false \
COMPOSER_ALLOW_SUPERUSER=1
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["coolify-api-start"]
+1 -1
View File
@@ -10,7 +10,7 @@ $CONFIG_DB = [
$DEBUG = true; // Set to true to enable debugging (Error messages will be shown, and this should never be used in production)
$USE_PROD_ECONOMIC_IN_DEBUG = true; // Set to true to use the production economic API in debug mode
$ENCRYPTION_KEY = ''; // 44 Characters long encryption key
$CORS = '*'; // Set to the domain that should be allowed to access the API e.g. https://example.com
$CORS = '*'; // Set to comma-separated allowed origins e.g. https://example.com,https://api-v2.truckwash.io
$ECONOMIC_API = [
'app_access_grant' => '', // Economic API access grant token (1)
'app_access_grant2' => '', // Economic API access grant token (2)
+15 -1
View File
@@ -96,6 +96,13 @@ services:
- "traefik.http.routers.edge-broker-api-io.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-io.priority=200"
- "traefik.http.routers.edge-broker-api-io.service=edge-broker"
- "traefik.http.routers.edge-broker-api-v2.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-v2.entrypoints=websecure"
- "traefik.http.routers.edge-broker-api-v2.tls=true"
- "traefik.http.routers.edge-broker-api-v2.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-api-v2.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-v2.priority=200"
- "traefik.http.routers.edge-broker-api-v2.service=edge-broker"
- "traefik.http.routers.edge-broker-api-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-api-staging.tls=true"
@@ -153,7 +160,14 @@ services:
- "traefik.http.routers.api-io.tls.certresolver=le_io"
- "traefik.http.routers.api-io.service=caddy"
- "traefik.http.routers.api-io.middlewares=secure-headers@file,api-ratelimit@file"
- "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`)"
- "traefik.http.routers.api-v2.rule=Host(`api-v2.truckwash.io`)"
- "traefik.http.routers.api-v2.entrypoints=websecure"
- "traefik.http.routers.api-v2.tls=true"
- "traefik.http.routers.api-v2.tls.domains[0].main=api-v2.truckwash.io"
- "traefik.http.routers.api-v2.tls.certresolver=le_io"
- "traefik.http.routers.api-v2.service=caddy"
- "traefik.http.routers.api-v2.middlewares=secure-headers@file,api-ratelimit@file"
- "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`) || Host(`api-v2.truckwash.io`)"
- "traefik.http.routers.api-http.entrypoints=web"
- "traefik.http.routers.api-http.middlewares=redirect-to-https@file"
- "traefik.http.routers.api-http.service=caddy"
+1 -1
View File
@@ -27,5 +27,5 @@ services:
## docker compose up -d traefik caddy php1 php2 php3 php4 php5 db redis
##
## Notes:
## - Traefik uses Lets Encrypt production. Ensure DNS A/AAAA records for api.truckwash.dk, api.truckwash.io and traefik.truckwash.dk point to this host and ports 80/443 are reachable.
## - Traefik uses Lets Encrypt production. Ensure DNS A/AAAA records for api.truckwash.dk, api.truckwash.io, api-v2.truckwash.io and traefik.truckwash.dk point to the expected ingress and ports 80/443 are reachable.
## - The dashboard is protected by basic auth and an IP allowlist (defined in dynamic.yml). Replace the bcrypt hash before enabling in production.
+23 -2
View File
@@ -7,7 +7,6 @@ services:
- "${TRAEFIK_WEB_PORT:-80}:80"
- "${TRAEFIK_WEBSECURE_PORT:-443}:443"
- "${TRAEFIK_WEBSECURE_STAGING_PORT:-4433}:4433"
- "${TRAEFIK_EDGE_BROKER_PORT:-4300}:4300"
# Prometheus metrics endpoint (local dev)
- "${TRAEFIK_METRICS_PORT:-9100}:9100"
volumes:
@@ -50,6 +49,13 @@ services:
- "traefik.http.routers.edge-broker-io.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-io.priority=200"
- "traefik.http.routers.edge-broker-io.service=edge-broker"
- "traefik.http.routers.edge-broker-v2.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-v2.entrypoints=websecure"
- "traefik.http.routers.edge-broker-v2.tls=true"
- "traefik.http.routers.edge-broker-v2.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-v2.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-v2.priority=200"
- "traefik.http.routers.edge-broker-v2.service=edge-broker"
- "traefik.http.routers.edge-broker-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-staging.tls=true"
@@ -134,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"
@@ -193,8 +206,16 @@ services:
- "traefik.http.routers.api-io.tls.certresolver=le_io"
- "traefik.http.routers.api-io.service=caddy"
- "traefik.http.routers.api-io.middlewares=secure-headers@file,api-ratelimit@file"
# Public API (.io load-balanced gateway)
- "traefik.http.routers.api-v2.rule=Host(`api-v2.truckwash.io`)"
- "traefik.http.routers.api-v2.entrypoints=websecure"
- "traefik.http.routers.api-v2.tls=true"
- "traefik.http.routers.api-v2.tls.domains[0].main=api-v2.truckwash.io"
- "traefik.http.routers.api-v2.tls.certresolver=le_io"
- "traefik.http.routers.api-v2.service=caddy"
- "traefik.http.routers.api-v2.middlewares=secure-headers@file,api-ratelimit@file"
# HTTP to HTTPS redirect for both API domains
- "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`)"
- "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`) || Host(`api-v2.truckwash.io`)"
- "traefik.http.routers.api-http.entrypoints=web"
- "traefik.http.routers.api-http.middlewares=redirect-to-https@file"
- "traefik.http.routers.api-http.service=caddy"
+3 -3
View File
@@ -38,14 +38,14 @@ http {
location / {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
add_header Access-Control-Allow-Credentials true;
# If OPTIONS method is needed for preflight
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
return 204; # No Content
}
@@ -65,4 +65,4 @@ http {
location ~* \.(cgi|shtml|phtml)$ {
}
}
}
}
+3 -3
View File
@@ -52,14 +52,14 @@ http {
location / {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
add_header Access-Control-Allow-Credentials true;
# If OPTIONS method is needed for preflight
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number";
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version";
return 204; # No Content
}
@@ -80,4 +80,4 @@ http {
# Additional SSL options or configurations can be placed here, if necessary.
}
}
}
}
+930 -10
View File
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env sh
set -eu
suite="${1:-}"
case "$suite" in
unit|integration|api|legacy|all)
;;
*)
echo "Usage: $0 <unit|integration|api|legacy|all>" >&2
exit 2
;;
esac
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
repo_root="$(CDPATH= cd -- "$script_dir/.." && pwd)"
cd "$repo_root"
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
project_suffix="$(date +%s)-$$"
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
log_dir=".tmp/ci-logs/$suite"
mkdir -p "$log_dir"
env_backup_dir=".tmp/php-ci-env-backup-$project_suffix"
mkdir -p "$env_backup_dir"
had_env=0
had_env_staging=0
if [ -f .env ]; then
cp .env "$env_backup_dir/env"
had_env=1
fi
if [ -f .env.staging ]; then
cp .env.staging "$env_backup_dir/env.staging"
had_env_staging=1
fi
cp .github/ci.env .env
cp .github/ci.env.staging .env.staging
collect_logs() {
status="$1"
if [ "$status" -eq 0 ]; then
return
fi
mkdir -p "$log_dir"
docker compose $compose_files ps > "$log_dir/docker-compose-ps.txt" 2>&1 || true
docker compose $compose_files logs --no-color > "$log_dir/docker-compose.log" 2>&1 || true
docker compose $compose_files cp php1:/var/www/html/build/logs "$log_dir/app-build-logs" >/dev/null 2>&1 || true
docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true
}
cleanup() {
status="$?"
collect_logs "$status"
docker compose $compose_files down -v >/dev/null 2>&1 || true
if [ "$had_env" -eq 1 ]; then
cp "$env_backup_dir/env" .env
else
rm -f .env
fi
if [ "$had_env_staging" -eq 1 ]; then
cp "$env_backup_dir/env.staging" .env.staging
else
rm -f .env.staging
fi
rm -rf "$env_backup_dir"
exit "$status"
}
trap cleanup EXIT INT TERM
docker compose $compose_files up -d redis mysql-debug php1
docker compose $compose_files exec -T php1 sh -lc '
set -eu
for i in $(seq 1 90); do
if MYSQL_PWD="${CONFIG_DB_PASSWORD:-debug_root_password}" mysqladmin \
-h "${CONFIG_DB_HOST:-mysql-debug}" \
-P "${CONFIG_DB_PORT:-3306}" \
-u "${CONFIG_DB_USER:-root}" \
ping --silent >/dev/null 2>&1; then
exit 0
fi
sleep 1
done
echo "Timed out waiting for mysql-debug" >&2
exit 1
'
tar \
--exclude='./vendor' \
--exclude='./.phpunit.cache' \
--exclude='./build/logs' \
-C services/nginx/app -cf - . \
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
docker compose $compose_files exec -T php1 sh -lc \
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'
docker compose $compose_files exec -T php1 sh -lc \
"cd /var/www/html && composer test:ci:$suite"
+8 -2
View File
@@ -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`);
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env sh
set -eu
suite="${1:-}"
case "$suite" in
unit|integration|api|legacy|all)
;;
*)
echo "Usage: $0 <unit|integration|api|legacy|all>" >&2
exit 2
;;
esac
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
repo_root="$(CDPATH= cd -- "$script_dir/.." && pwd)"
cd "$repo_root"
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
project_suffix="$(date +%s)-$$"
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
log_dir=".tmp/ci-logs/$suite"
mkdir -p "$log_dir"
env_backup_dir=".tmp/php-ci-env-backup-$project_suffix"
mkdir -p "$env_backup_dir"
had_env=0
had_env_staging=0
if [ -f .env ]; then
cp .env "$env_backup_dir/env"
had_env=1
fi
if [ -f .env.staging ]; then
cp .env.staging "$env_backup_dir/env.staging"
had_env_staging=1
fi
cp .github/ci.env .env
cp .github/ci.env.staging .env.staging
collect_logs() {
status="$1"
if [ "$status" -eq 0 ]; then
return
fi
mkdir -p "$log_dir"
docker compose $compose_files ps > "$log_dir/docker-compose-ps.txt" 2>&1 || true
docker compose $compose_files logs --no-color > "$log_dir/docker-compose.log" 2>&1 || true
docker compose $compose_files cp php1:/var/www/html/build/logs "$log_dir/app-build-logs" >/dev/null 2>&1 || true
docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true
}
cleanup() {
status="$?"
collect_logs "$status"
docker compose $compose_files down -v >/dev/null 2>&1 || true
if [ "$had_env" -eq 1 ]; then
cp "$env_backup_dir/env" .env
else
rm -f .env
fi
if [ "$had_env_staging" -eq 1 ]; then
cp "$env_backup_dir/env.staging" .env.staging
else
rm -f .env.staging
fi
rm -rf "$env_backup_dir"
exit "$status"
}
trap cleanup EXIT INT TERM
docker compose $compose_files up -d redis mysql-debug php1
docker compose $compose_files exec -T php1 sh -lc '
set -eu
for i in $(seq 1 90); do
if MYSQL_PWD="${CONFIG_DB_PASSWORD:-debug_root_password}" mysqladmin \
-h "${CONFIG_DB_HOST:-mysql-debug}" \
-P "${CONFIG_DB_PORT:-3306}" \
-u "${CONFIG_DB_USER:-root}" \
ping --silent >/dev/null 2>&1; then
exit 0
fi
sleep 1
done
echo "Timed out waiting for mysql-debug" >&2
exit 1
'
tar \
--exclude='./vendor' \
--exclude='./.phpunit.cache' \
--exclude='./build/logs' \
-C services/nginx/app -cf - . \
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
docker compose $compose_files exec -T php1 sh -lc \
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'
docker compose $compose_files exec -T php1 sh -lc \
"cd /var/www/html && composer test:ci:$suite"
+15 -3
View File
@@ -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 = "") {
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
worker_processes auto;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
error_log /dev/stderr warn;
sendfile on;
keepalive_timeout 65;
client_max_body_size 64m;
gzip on;
gzip_types application/json application/javascript application/xml text/css text/plain;
server {
listen 80 default_server;
server_name _;
root /var/www/html;
index index.php;
location / {
include fastcgi_params;
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_read_timeout 60s;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param X_REQUEST_ID $http_x_request_id;
}
location ~ /\.(?!well-known) {
deny all;
}
}
}
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
mkdir -p /run/nginx
php-fpm -D
exec nginx -g "daemon off;"
@@ -45,6 +45,7 @@ test("base docker compose routes edge broker traffic through traefik", () => {
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-io\.rule=Host\(`api\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-v2\.rule=Host\(`api-v2\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.middlewares\.edge-broker-strip\.stripPrefix\.prefixes=\/edge-broker/);
assert.match(serviceBlock, /traefik\.http\.middlewares\.edge-broker-strip-local\.stripPrefix\.prefixes=\/api\/edge-broker/);
@@ -70,6 +71,7 @@ test("standalone production compose routes edge broker traffic through traefik",
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-io\.rule=Host\(`api\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-v2\.rule=Host\(`api-v2\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
});
File diff suppressed because one or more lines are too long
@@ -12120,3 +12120,87 @@
[Mon Apr 27 16:09:11 2026] 127.0.0.1:40070 Closing
[Mon Apr 27 16:09:12 2026] 127.0.0.1:41964 Accepted
[Mon Apr 27 16:09:54 2026] 127.0.0.1:41964 Closing
[Tue May 26 09:18:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:35029) started
[Tue May 26 09:18:49 2026] 127.0.0.1:35080 Accepted
[Tue May 26 09:18:53 2026] 127.0.0.1:35080 Closing
[Tue May 26 09:18:53 2026] 127.0.0.1:35088 Accepted
[Tue May 26 09:18:56 2026] 127.0.0.1:35088 Closing
[Tue May 26 09:18:58 2026] 127.0.0.1:39708 Accepted
[Tue May 26 09:19:01 2026] 127.0.0.1:39708 Closing
[Tue May 26 09:19:02 2026] 127.0.0.1:39714 Accepted
[Tue May 26 09:19:06 2026] 127.0.0.1:39714 Closing
[Tue May 26 09:19:05 2026] 127.0.0.1:38256 Accepted
[Tue May 26 09:19:08 2026] 127.0.0.1:38256 Closing
[Tue May 26 09:19:09 2026] 127.0.0.1:38268 Accepted
[Tue May 26 09:19:12 2026] 127.0.0.1:38268 Closing
[Tue May 26 09:19:13 2026] 127.0.0.1:37926 Accepted
[Tue May 26 09:19:17 2026] 127.0.0.1:37926 Closing
[Tue May 26 09:21:24 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33343) started
[Tue May 26 09:21:25 2026] 127.0.0.1:53732 Accepted
[Tue May 26 09:21:27 2026] 127.0.0.1:53732 Closing
[Tue May 26 09:21:27 2026] 127.0.0.1:55472 Accepted
[Tue May 26 09:21:30 2026] 127.0.0.1:55472 Closing
[Tue May 26 09:21:32 2026] 127.0.0.1:55484 Accepted
[Tue May 26 09:21:35 2026] 127.0.0.1:55484 Closing
[Tue May 26 09:21:36 2026] 127.0.0.1:44478 Accepted
[Tue May 26 09:21:40 2026] 127.0.0.1:44478 Closing
[Tue May 26 09:21:41 2026] 127.0.0.1:44486 Accepted
[Tue May 26 09:21:46 2026] 127.0.0.1:44486 Closing
[Tue May 26 09:21:47 2026] 127.0.0.1:50508 Accepted
[Tue May 26 09:21:53 2026] 127.0.0.1:50508 Closing
[Tue May 26 09:21:55 2026] 127.0.0.1:42034 Accepted
[Tue May 26 09:22:00 2026] 127.0.0.1:42034 Closing
[Tue May 26 09:23:59 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41083) started
[Tue May 26 09:23:59 2026] 127.0.0.1:45896 Accepted
[Tue May 26 09:24:04 2026] 127.0.0.1:45896 Closing
[Tue May 26 09:24:04 2026] 127.0.0.1:45908 Accepted
[Tue May 26 09:24:07 2026] 127.0.0.1:45908 Closing
[Tue May 26 09:25:43 2026] PHP 8.2.15 Development Server (http://127.0.0.1:38485) started
[Tue May 26 09:25:43 2026] 127.0.0.1:34288 Accepted
[Tue May 26 09:25:47 2026] 127.0.0.1:34288 Closing
[Tue May 26 09:25:47 2026] 127.0.0.1:34290 Accepted
[Tue May 26 09:25:51 2026] 127.0.0.1:34290 Closing
[Tue May 26 09:25:51 2026] 127.0.0.1:41802 Accepted
[Tue May 26 09:25:55 2026] 127.0.0.1:41802 Closing
[Tue May 26 09:27:23 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37463) started
[Tue May 26 09:27:24 2026] 127.0.0.1:43206 Accepted
[Tue May 26 09:27:26 2026] 127.0.0.1:43206 Closing
[Tue May 26 09:27:26 2026] 127.0.0.1:43212 Accepted
[Tue May 26 09:27:33 2026] 127.0.0.1:43212 Closing
[Tue May 26 09:27:34 2026] 127.0.0.1:51102 Accepted
[Tue May 26 09:27:39 2026] 127.0.0.1:51102 Closing
[Tue May 26 09:28:26 2026] PHP 8.2.15 Development Server (http://127.0.0.1:46559) started
[Tue May 26 09:28:26 2026] 127.0.0.1:59372 Accepted
[Tue May 26 09:28:28 2026] 127.0.0.1:59372 Closing
[Tue May 26 09:28:28 2026] 127.0.0.1:59378 Accepted
[Tue May 26 09:28:30 2026] 127.0.0.1:59378 Closing
[Tue May 26 09:28:31 2026] 127.0.0.1:54846 Accepted
[Tue May 26 09:28:31 2026] 127.0.0.1:54846 Closing
[Tue May 26 09:28:33 2026] 127.0.0.1:54850 Accepted
[Tue May 26 09:28:34 2026] 127.0.0.1:54850 Closing
[Tue May 26 09:28:35 2026] 127.0.0.1:54854 Accepted
[Tue May 26 09:28:36 2026] 127.0.0.1:54854 Closing
[Tue May 26 09:28:36 2026] 127.0.0.1:54864 Accepted
[Tue May 26 09:28:41 2026] 127.0.0.1:54864 Closing
[Tue May 26 09:28:42 2026] 127.0.0.1:52868 Accepted
[Tue May 26 09:28:46 2026] 127.0.0.1:52868 Closing
[Tue May 26 09:30:14 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41205) started
[Tue May 26 09:30:14 2026] 127.0.0.1:56928 Accepted
[Tue May 26 09:30:15 2026] 127.0.0.1:56928 Closing
[Tue May 26 09:30:15 2026] 127.0.0.1:56938 Accepted
[Tue May 26 09:30:18 2026] 127.0.0.1:56938 Closing
[Tue May 26 09:30:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44523) started
[Tue May 26 09:30:49 2026] 127.0.0.1:54004 Accepted
[Tue May 26 09:30:52 2026] 127.0.0.1:54004 Closing
[Tue May 26 09:30:52 2026] 127.0.0.1:38720 Accepted
[Tue May 26 09:30:51 2026] 127.0.0.1:38720 Closing
[Tue May 26 09:30:53 2026] 127.0.0.1:38734 Accepted
[Tue May 26 09:30:56 2026] 127.0.0.1:38734 Closing
[Tue May 26 09:30:57 2026] 127.0.0.1:38738 Accepted
[Tue May 26 09:31:00 2026] 127.0.0.1:38738 Closing
[Tue May 26 09:31:01 2026] 127.0.0.1:38278 Accepted
[Tue May 26 09:31:02 2026] 127.0.0.1:38278 Closing
[Tue May 26 09:31:03 2026] 127.0.0.1:38284 Accepted
[Tue May 26 09:31:07 2026] 127.0.0.1:38284 Closing
[Tue May 26 09:31:08 2026] 127.0.0.1:38290 Accepted
[Tue May 26 09:31:12 2026] 127.0.0.1:38290 Closing
@@ -0,0 +1,110 @@
<?php
namespace classes;
use RuntimeException;
class application_write_freeze
{
public static function freeze(string $reason, string $owner, int $ttlSeconds = 300): void
{
$payload = [
'reason' => $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';
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace classes;
require_once WD . '/interfaces/universal_module_i.php';
require_once WD . '/modules/coolify/coolify_c.php';
use Exception;
use interfaces\universal_module_i;
use modules\coolify\coolify_c;
class coolify implements universal_module_i
{
public coolify_c $config;
public function __construct()
{
$this->config = new coolify_c();
}
/**
* @throws Exception
*/
public function requireModuleEnabled(): void
{
if (!$this->config->enabled->isTrue()) {
throw new Exception('The Coolify module is not enabled');
}
}
public function isEnabled(): bool
{
try {
return $this->config->enabled->isTrue();
} catch (Exception) {
return false;
}
}
}
@@ -0,0 +1,264 @@
<?php
namespace classes;
use RuntimeException;
class coolify_api_client
{
private string $baseUrl;
private string $token;
private int $timeoutSeconds;
public function __construct(string $baseUrl, string $token, int $timeoutSeconds = 4)
{
$this->baseUrl = self::normalizeBaseUrl($baseUrl);
$this->token = trim($token);
$this->timeoutSeconds = max(1, $timeoutSeconds);
if ($this->baseUrl === '' || $this->token === '') {
throw new RuntimeException('Coolify base URL and API token are required.');
}
}
public static function normalizeBaseUrl(string $baseUrl): string
{
$baseUrl = rtrim(trim($baseUrl), '/');
if ($baseUrl === '') {
return '';
}
if (preg_match('#/api/v[0-9]+$#i', $baseUrl) === 1) {
return $baseUrl;
}
return $baseUrl . '/api/v1';
}
public function healthcheck(): array
{
return $this->request('GET', '/health', null, false);
}
public function version(): array
{
return $this->request('GET', '/version');
}
public function listServers(): array
{
return $this->request('GET', '/servers');
}
public function listProjects(): array
{
return $this->request('GET', '/projects');
}
public function listProjectEnvironments(string $projectUuid): array
{
return $this->request('GET', '/projects/' . rawurlencode($projectUuid) . '/environments');
}
public function listServices(): array
{
return $this->request('GET', '/services');
}
public function listGithubApps(): array
{
return $this->request('GET', '/github-apps');
}
public function getService(string $uuid): array
{
return $this->request('GET', '/services/' . rawurlencode($uuid));
}
public function createService(array $payload): array
{
return $this->request('POST', '/services', $payload);
}
public function createPrivateGithubAppApplication(array $payload): array
{
return $this->request('POST', '/applications/private-github-app', $payload);
}
public function getApplication(string $uuid): array
{
return $this->request('GET', '/applications/' . rawurlencode($uuid));
}
public function updateApplication(string $uuid, array $payload): array
{
return $this->request('PATCH', '/applications/' . rawurlencode($uuid), $payload);
}
public function updateService(string $uuid, array $payload): array
{
return $this->request('PATCH', '/services/' . rawurlencode($uuid), $payload);
}
public function updateServiceEnvsBulk(string $uuid, array $env): array
{
if ($env === []) {
return [];
}
return $this->request('PATCH', '/services/' . rawurlencode($uuid) . '/envs/bulk', [
'data' => self::bulkEnvData($env),
]);
}
public function updateApplicationEnvsBulk(string $uuid, array $env): array
{
if ($env === []) {
return [];
}
return $this->request('PATCH', '/applications/' . rawurlencode($uuid) . '/envs/bulk', [
'data' => self::bulkEnvData($env),
]);
}
private static function bulkEnvData(array $env): array
{
$data = [];
foreach ($env as $key => $value) {
$data[] = [
'key' => (string)$key,
'value' => (string)$value,
'is_preview' => false,
'is_literal' => true,
'is_multiline' => str_contains((string)$value, "\n"),
'is_shown_once' => false,
];
}
return $data;
}
public function deployResource(string $uuid, bool $force = false): array
{
$path = '/deploy?uuid=' . rawurlencode($uuid) . '&force=' . ($force ? 'true' : 'false');
return $this->request('GET', $path);
}
public function startService(string $uuid): array
{
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/start');
}
public function restartService(string $uuid): array
{
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/restart');
}
public function restartApplication(string $uuid): array
{
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
}
public function deleteService(string $uuid): array
{
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
}
public function listDeployments(): array
{
return $this->request('GET', '/deployments');
}
protected function request(string $method, string $path, ?array $payload = null, bool $versionedApi = true): array
{
$url = ($versionedApi ? $this->baseUrl : $this->apiRootUrl()) . '/' . ltrim($path, '/');
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Could not initialize Coolify API request.');
}
$headers = [
'Accept: application/json',
'Authorization: Bearer ' . $this->token,
];
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method));
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, min(2, $this->timeoutSeconds));
curl_setopt($curl, CURLOPT_TIMEOUT, $this->timeoutSeconds);
curl_setopt($curl, CURLOPT_NOSIGNAL, true);
curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
if ($payload !== null) {
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($body === false) {
throw new RuntimeException('Could not encode Coolify API payload.');
}
$headers[] = 'Content-Type: application/json';
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
}
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
$raw = curl_exec($curl);
$error = curl_error($curl);
$status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($raw === false) {
throw new RuntimeException('Coolify API request failed: ' . $error);
}
$decoded = null;
if (trim((string)$raw) !== '') {
$decoded = json_decode((string)$raw, true);
if (!is_array($decoded)) {
$decoded = ['raw' => (string)$raw];
}
}
if ($status < 200 || $status >= 300) {
$message = is_array($decoded)
? (string)($decoded['message'] ?? $decoded['error'] ?? ('HTTP ' . $status))
: ('HTTP ' . $status);
if (is_array($decoded)) {
$details = self::validationErrorSummary($decoded);
if ($details !== '') {
$message .= ': ' . $details;
}
}
throw new RuntimeException('Coolify API request failed: ' . $message);
}
return is_array($decoded) ? $decoded : [];
}
private static function validationErrorSummary(array $decoded): string
{
$errors = $decoded['errors'] ?? $decoded['data']['errors'] ?? null;
if (!is_array($errors)) {
return '';
}
$parts = [];
foreach ($errors as $field => $messages) {
$fieldName = trim((string)$field);
$fieldPrefix = $fieldName !== '' ? $fieldName . ': ' : '';
if (is_array($messages)) {
$messages = implode(', ', array_filter(array_map(static fn(mixed $message): string => trim((string)$message), $messages)));
} else {
$messages = trim((string)$messages);
}
if ($messages !== '') {
$parts[] = $fieldPrefix . $messages;
}
}
return implode('; ', array_slice($parts, 0, 5));
}
private function apiRootUrl(): string
{
return preg_replace('#/v[0-9]+$#i', '', $this->baseUrl) ?: $this->baseUrl;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,237 @@
<?php
namespace classes;
/**
* Additive schema bootstrap for the Coolify infrastructure integration.
*
* The legacy stack has no centralized migration runner, so this class must be
* safe to call from request handlers, cron, and tests.
*/
class coolify_schema_bootstrap
{
private static bool $initialized = false;
private static ?bool $tablesExist = null;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
$queries = [
"CREATE TABLE IF NOT EXISTS coolify_instances (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
label VARCHAR(128) NOT NULL,
base_url VARCHAR(512) NOT NULL,
api_token_secret TEXT NOT NULL,
default_project_uuid VARCHAR(128) NULL,
default_environment_uuid VARCHAR(128) NULL,
default_environment_name VARCHAR(128) NULL,
default_server_uuid VARCHAR(128) NULL,
default_destination_uuid VARCHAR(128) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'unknown',
last_checked_at DATETIME NULL,
last_error TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
INDEX idx_coolify_instances_status (status),
INDEX idx_coolify_instances_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS coolify_targets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
instance_id BIGINT UNSIGNED NOT NULL,
replication_host_id BIGINT UNSIGNED NULL,
kind VARCHAR(16) NOT NULL,
label VARCHAR(128) NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'replica',
server_uuid VARCHAR(128) NULL,
project_uuid VARCHAR(128) NULL,
environment_uuid VARCHAR(128) NULL,
environment_name VARCHAR(128) NULL,
destination_uuid VARCHAR(128) NULL,
resource_uuid VARCHAR(128) NULL,
resource_type VARCHAR(32) NOT NULL DEFAULT 'service',
resource_name VARCHAR(128) NULL,
deployment_status VARCHAR(32) NOT NULL DEFAULT 'pending',
availability_state VARCHAR(32) NOT NULL DEFAULT 'degraded',
desired_compose_hash CHAR(64) NULL,
last_reconcile_status VARCHAR(32) NULL,
last_reconcile_json LONGTEXT NULL,
last_reconciled_at DATETIME NULL,
options_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
INDEX idx_coolify_targets_instance (instance_id),
INDEX idx_coolify_targets_replication_host (replication_host_id),
INDEX idx_coolify_targets_kind_status (kind, deployment_status),
INDEX idx_coolify_targets_availability (availability_state),
INDEX idx_coolify_targets_resource_uuid (resource_uuid),
INDEX idx_coolify_targets_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS coolify_operations (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
target_id BIGINT UNSIGNED NULL,
instance_id BIGINT UNSIGNED NULL,
operation VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'running',
guarded TINYINT(1) NOT NULL DEFAULT 1,
message VARCHAR(512) NULL,
error_message TEXT NULL,
context_json LONGTEXT NULL,
actor_user_id INT NULL,
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_coolify_operations_target (target_id),
INDEX idx_coolify_operations_instance (instance_id),
INDEX idx_coolify_operations_status (status),
INDEX idx_coolify_operations_operation (operation)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS coolify_audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
target_id BIGINT UNSIGNED NULL,
instance_id BIGINT UNSIGNED NULL,
replication_host_id BIGINT UNSIGNED NULL,
action VARCHAR(64) NOT NULL,
actor_user_id INT NULL,
severity VARCHAR(16) NOT NULL DEFAULT 'info',
context_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_coolify_audit_target (target_id),
INDEX idx_coolify_audit_instance (instance_id),
INDEX idx_coolify_audit_replication_host (replication_host_id),
INDEX idx_coolify_audit_action (action),
INDEX idx_coolify_audit_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS coolify_instance_gateways (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
instance_id BIGINT UNSIGNED NULL,
hostname VARCHAR(255) NOT NULL,
target_ip VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
priority INT NOT NULL DEFAULT 100,
health_state VARCHAR(32) NOT NULL DEFAULT 'unknown',
lb_state VARCHAR(32) NOT NULL DEFAULT 'unknown',
last_probe_json LONGTEXT NULL,
last_probed_at DATETIME NULL,
last_reconciled_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
UNIQUE KEY uniq_coolify_instance_gateways_target_ip (target_ip),
INDEX idx_coolify_instance_gateways_instance (instance_id),
INDEX idx_coolify_instance_gateways_enabled (enabled),
INDEX idx_coolify_instance_gateways_lb_state (lb_state),
INDEX idx_coolify_instance_gateways_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $sql) {
$db->query($sql);
}
self::ensureColumn('coolify_instances', 'default_destination_uuid', 'VARCHAR(128) NULL');
self::ensureColumn('coolify_targets', 'availability_state', "VARCHAR(32) NOT NULL DEFAULT 'degraded'");
self::ensureColumn('coolify_targets', 'desired_compose_hash', 'CHAR(64) NULL');
self::ensureColumn('coolify_targets', 'last_reconcile_json', 'LONGTEXT NULL');
self::ensureColumn('coolify_operations', 'guarded', 'TINYINT(1) NOT NULL DEFAULT 1');
self::ensureColumn('coolify_instance_gateways', 'last_reconciled_at', 'DATETIME NULL');
self::ensureModuleConfigDefault('Coolify', 'enabled', 'false', 'bool');
self::ensureModuleConfigDefault('Coolify', 'lb_automation_enabled', 'false', 'bool');
self::ensureModuleConfigDefault('Coolify', 'lb_automation_mode', 'report_only', 'string');
self::ensureModuleConfigDefault('Coolify', 'hetzner_load_balancer_id', '', 'string');
self::ensureModuleConfigDefault('Coolify', 'hetzner_cloud_api_token', '', 'string');
self::ensureModuleConfigDefault('Coolify', 'public_gateway_host', 'api-v2.truckwash.io', 'string');
self::ensureModuleConfigDefault('Coolify', 'public_gateway_probe_path', '', 'string');
self::ensureDefaultGateway('node1.truckwash.io', '94.130.142.41', 10);
self::ensureDefaultGateway('node2.truckwash.io', '65.21.214.30', 20);
self::ensureDefaultGateway('node3.truckwash.io', '23.88.23.183', 30);
self::$initialized = true;
self::$tablesExist = true;
}
public static function tablesExist(): bool
{
if (self::$tablesExist !== null) {
return self::$tablesExist;
}
global $db;
foreach (['coolify_instances', 'coolify_targets', 'coolify_operations', 'coolify_audit_logs', 'coolify_instance_gateways'] as $table) {
$tableSql = $db->escape_string($table);
$result = $db->query("SHOW TABLES LIKE '$tableSql'");
if ($result === false || $result->num_rows === 0) {
self::$tablesExist = false;
return false;
}
}
self::$tablesExist = true;
return self::$tablesExist;
}
private static function ensureColumn(string $table, string $column, string $definition): void
{
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
if ($table === '' || $column === '') {
return;
}
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
if ($result !== false && $result->num_rows > 0) {
return;
}
$db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
}
private static function ensureModuleConfigDefault(string $module, string $variable, string $value, string $type): void
{
global $db;
$moduleSql = $db->escape_string($module);
$variableSql = $db->escape_string($variable);
$result = $db->query("SELECT value FROM module_config WHERE module = '$moduleSql' AND variable = '$variableSql' LIMIT 1");
if ($result !== false && $result->num_rows > 0) {
return;
}
$valueSql = $db->escape_string($value);
$typeSql = $db->escape_string($type);
$db->query("INSERT INTO module_config (module, variable, value, type) VALUES ('$moduleSql', '$variableSql', '$valueSql', '$typeSql')");
}
private static function ensureDefaultGateway(string $hostname, string $targetIp, int $priority): void
{
global $db;
$targetIpSql = $db->escape_string($targetIp);
$result = $db->query("SELECT id FROM coolify_instance_gateways WHERE target_ip = '$targetIpSql' LIMIT 1");
if ($result !== false && $result->num_rows > 0) {
return;
}
$hostnameSql = $db->escape_string($hostname);
$db->query(
"INSERT INTO coolify_instance_gateways (hostname, target_ip, enabled, priority)
VALUES ('$hostnameSql', '$targetIpSql', 1, " . (int)$priority . ")"
);
}
}
+188
View File
@@ -0,0 +1,188 @@
<?php
namespace classes;
class cors_policy
{
public const ALLOWED_HEADERS = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
public const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
public const MAX_AGE_SECONDS = '86400';
private const REQUIRED_ALLOWED_ORIGINS = [
'https://truckwash.io',
'https://www.truckwash.io',
'https://api.truckwash.io',
'https://api.truckwash.io:4433',
'https://api-v2.truckwash.io',
'https://web.truckwash.dk',
'https://api.truckwash.dk',
'https://truckwash.dk',
'https://www.truckwash.dk',
'https://staging.truckwash.io',
'http://localhost',
'https://localhost',
'http://localhost:4433',
'https://localhost:4433',
'https://twdev.jeppeb.dk',
'http://localhost:5173',
];
public static function normalizeOrigin(?string $value): string
{
$value = trim((string)$value);
if ($value === '' || $value === '*') {
return $value;
}
if (preg_match('#^https?://#i', $value) !== 1) {
return '';
}
$parts = parse_url($value);
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
return '';
}
$scheme = strtolower((string)$parts['scheme']);
if (!in_array($scheme, ['http', 'https'], true)) {
return '';
}
$host = strtolower((string)$parts['host']);
$port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';
return $scheme . '://' . $host . $port;
}
/**
* @return array<int,string>
*/
public static function requiredAllowedOrigins(): array
{
return self::REQUIRED_ALLOWED_ORIGINS;
}
/**
* @return array<int,string>
*/
public static function allowedOrigins(string $corsConfig): array
{
$origins = [];
foreach (self::splitOrigins($corsConfig) as $configuredOrigin) {
if ($configuredOrigin === '*') {
return ['*'];
}
$origin = self::normalizeOrigin($configuredOrigin);
if ($origin !== '') {
$origins[$origin] = true;
}
}
foreach (self::REQUIRED_ALLOWED_ORIGINS as $requiredOrigin) {
$origin = self::normalizeOrigin($requiredOrigin);
if ($origin !== '') {
$origins[$origin] = true;
}
}
return array_keys($origins);
}
public static function withRequiredOrigins(string $corsConfig): string
{
$allowedOrigins = self::allowedOrigins($corsConfig);
if ($allowedOrigins === ['*']) {
return '*';
}
return implode(',', $allowedOrigins);
}
public static function isOriginAllowed(?string $origin, string $corsConfig): bool
{
$origin = self::normalizeOrigin($origin);
if ($origin === '' || $origin === '*') {
return false;
}
$allowedOrigins = self::allowedOrigins($corsConfig);
return in_array('*', $allowedOrigins, true) || in_array($origin, $allowedOrigins, true);
}
/**
* @return array<string,string>
*/
public static function responseHeaders(?string $origin, string $corsConfig): array
{
$origin = self::normalizeOrigin($origin);
if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) {
return [];
}
return [
'Access-Control-Allow-Origin' => $origin,
'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
'Vary' => 'Origin',
];
}
/**
* @return array{allowed:bool,status:int,headers:array<string,string>,body:string}
*/
public static function preflightResponse(?string $origin, string $corsConfig): array
{
$headers = self::responseHeaders($origin, $corsConfig);
if ($headers === []) {
return [
'allowed' => false,
'status' => 403,
'headers' => ['Content-Type' => 'application/json'],
'body' => json_encode(['success' => false, 'message' => 'CORS origin not allowed']) ?: '',
];
}
$headers['Content-Type'] = 'application/json';
return [
'allowed' => true,
'status' => 200,
'headers' => $headers,
'body' => '',
];
}
public static function applyResponseHeaders(string $corsConfig, ?string $origin = null): bool
{
$headers = self::responseHeaders($origin ?? ($_SERVER['HTTP_ORIGIN'] ?? ''), $corsConfig);
if ($headers === []) {
return false;
}
self::emitHeaders($headers);
return true;
}
/**
* @param array<string,string> $headers
*/
public static function emitHeaders(array $headers): void
{
foreach ($headers as $name => $value) {
header($name . ': ' . $value, strtolower((string)$name) !== 'vary');
}
}
/**
* @return array<int,string>
*/
private static function splitOrigins(string $corsConfig): array
{
return array_values(array_filter(
array_map('trim', explode(',', $corsConfig)),
static fn(string $origin): bool => $origin !== ''
));
}
}
@@ -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;
}
}
+8 -1
View File
@@ -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)
@@ -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"
);
@@ -0,0 +1,89 @@
<?php
namespace classes;
/**
* Ensures additive schema for department lifecycle metadata.
*/
class departments_schema_bootstrap
{
private static bool $initialized = false;
private const ARCHIVED_INDEX = 'idx_departments_archived';
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
if (!self::tableExists($db, 'departments')) {
return;
}
if (!self::columnExists($db, 'departments', 'archived')) {
$db->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);
}
}
+69 -3
View File
@@ -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;
}
/**
@@ -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.
*/
@@ -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
),
],
@@ -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;
}
}
@@ -892,6 +892,7 @@ class economic_v2_distribution_service
$weight_total = array_sum($eligible_weights);
if (abs($weight_total) <= self::EPSILON) {
$fallback_department_id = $this->getFallbackDistributionDepartmentId();
$warnings[] = 'Booked department 75 '
. $source_category
. ' amount for customer '
@@ -900,11 +901,15 @@ class economic_v2_distribution_service
. $month_key
. ' on invoice(s) '
. $invoice_ids
. ' has no redistribution basis and remains undistributed.';
. ' has no redistribution basis and was assigned to fallback department '
. $fallback_department_id
. '.';
return [
'department_distribution' => [],
'undistributed_net_amount' => $booked_amount,
'department_distribution' => [
$fallback_department_id => $booked_amount,
],
'undistributed_net_amount' => 0.0,
];
}
@@ -1061,6 +1066,9 @@ class economic_v2_distribution_service
} elseif (!$this->isOrderEligible($order)) {
continue;
}
$distribution_department_id = $system_order_fallback
? $this->getFallbackDistributionDepartmentId()
: $department_id;
$fixed_version = $this->versioning->resolveFixedPricingVersionAt($customer_number, $created_at);
if ($fixed_version === null) {
@@ -1090,14 +1098,14 @@ class economic_v2_distribution_service
$order_original_price = $this->calculateOrderOriginalPrice(
$order_items_by_order_id[$order_id] ?? [],
$customer_number,
$department_id,
$distribution_department_id,
$created_at
);
$groups[$group_key]['original_price'] += $order_original_price;
if (!isset($groups[$group_key]['department_totals'][$department_id])) {
$groups[$group_key]['department_totals'][$department_id] = 0.0;
if (!isset($groups[$group_key]['department_totals'][$distribution_department_id])) {
$groups[$group_key]['department_totals'][$distribution_department_id] = 0.0;
}
$groups[$group_key]['department_totals'][$department_id] += $order_original_price;
$groups[$group_key]['department_totals'][$distribution_department_id] += $order_original_price;
$groups[$group_key]['order_ids'][] = $order_id;
if (!isset($customer_transactions[$customer_number][$order_id])) {
@@ -1141,12 +1149,15 @@ class economic_v2_distribution_service
} elseif (!$this->isOrderEligible($order)) {
continue;
}
$distribution_department_id = $system_order_fallback
? $this->getFallbackDistributionDepartmentId()
: $department_id;
$month_key = substr($created_at, 0, 7);
if (!isset($customer_department_month_map[$customer_number][$month_key][$department_id])) {
$customer_department_month_map[$customer_number][$month_key][$department_id] = 0;
if (!isset($customer_department_month_map[$customer_number][$month_key][$distribution_department_id])) {
$customer_department_month_map[$customer_number][$month_key][$distribution_department_id] = 0;
}
$customer_department_month_map[$customer_number][$month_key][$department_id]++;
$customer_department_month_map[$customer_number][$month_key][$distribution_department_id]++;
$candidates = $this->buildWashSubscriptionCandidates(
$order,
@@ -1193,10 +1204,10 @@ class economic_v2_distribution_service
];
}
if (!isset($groups[$group_key]['distribution'][$department_id])) {
$groups[$group_key]['distribution'][$department_id] = 0;
if (!isset($groups[$group_key]['distribution'][$distribution_department_id])) {
$groups[$group_key]['distribution'][$distribution_department_id] = 0;
}
$groups[$group_key]['distribution'][$department_id]++;
$groups[$group_key]['distribution'][$distribution_department_id]++;
$groups[$group_key]['order_ids'][] = $order_id;
$matched_order = true;
}
@@ -1394,6 +1405,9 @@ class economic_v2_distribution_service
): array {
$distribution = [];
$department_counts = $customer_department_month_map[$customer_number][$month_key] ?? [];
if (!empty($department_counts)) {
$department_counts = $this->normalizeFallbackDepartmentCounts($department_counts);
}
if (!empty($department_counts)) {
$total = (float)array_sum($department_counts);
foreach ($department_counts as $department_id => $count) {
@@ -1402,20 +1416,61 @@ class economic_v2_distribution_service
return $distribution;
}
$default_department = 1;
try {
$default = (new users_o())->getUserByCustomerNumber($customer_number)->getDefaultDepartment();
if (!empty($default)) {
$default_department = (int)$default;
}
} catch (Exception $e) {
// fall back to department 1
}
$customer_default_department_id = $this->getCustomerDefaultDepartmentId($customer_number);
$default_department = $customer_default_department_id !== null && $customer_default_department_id > 0
? $customer_default_department_id
: $this->getFallbackDistributionDepartmentId();
$distribution[$default_department] = $monthly_price;
return $distribution;
}
/**
* @param array<int|string,int|float> $department_counts
* @return array<int,int|float>
*/
private function normalizeFallbackDepartmentCounts(array $department_counts): array
{
$normalized = [];
foreach ($department_counts as $department_id => $count) {
$department_id = (int)$department_id;
if ($department_id === self::SYSTEM_ORDER_DEPARTMENT_ID || $department_id <= 0) {
$department_id = $this->getFallbackDistributionDepartmentId();
}
if (!isset($normalized[$department_id])) {
$normalized[$department_id] = 0;
}
$normalized[$department_id] += $count;
}
return $normalized;
}
protected function getCustomerDefaultDepartmentId(int $customer_number): ?int
{
try {
$default = (new users_o())->getUserByCustomerNumber($customer_number)->getDefaultDepartment();
return !empty($default) ? (int)$default : null;
} catch (Exception $e) {
return null;
}
}
protected function getFallbackDistributionDepartmentId(): int
{
try {
$department_id = (new economic())->getDefaultDistributionDepartmentId();
} catch (\Throwable $e) {
$department_id = economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID;
}
if ($department_id <= 0 || $department_id === self::SYSTEM_ORDER_DEPARTMENT_ID) {
return economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID;
}
return $department_id;
}
private function normalizeSubscriptionGroupAllocation(array $distribution, float $monthly_price): array
{
if (empty($distribution)) {
@@ -0,0 +1,76 @@
<?php
namespace classes;
class error_report_schema_bootstrap
{
private static bool $initialized = false;
private static ?bool $tablesExist = null;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
$db->query("CREATE TABLE IF NOT EXISTS error_reports (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
status VARCHAR(16) NOT NULL DEFAULT 'open',
reporter_type VARCHAR(16) NOT NULL,
reporter_user_id INT NULL,
reporter_subuser_id INT NULL,
reporter_customer_number INT NULL,
reporter_customer_number_context INT NULL,
reporter_name VARCHAR(255) NULL,
reporter_email VARCHAR(255) NULL,
route_path VARCHAR(512) NULL,
page_url VARCHAR(1024) NULL,
release_trace_id VARCHAR(64) NULL,
frontend_version VARCHAR(128) NULL,
api_version VARCHAR(128) NULL,
screenshot_object_key VARCHAR(512) NOT NULL,
screenshot_mime_type VARCHAR(64) NOT NULL,
screenshot_size_bytes INT UNSIGNED NOT NULL DEFAULT 0,
before_error TEXT NOT NULL,
expected TEXT NOT NULL,
actual TEXT NOT NULL,
request_error_count INT UNSIGNED NOT NULL DEFAULT 0,
vue_error_count INT UNSIGNED NOT NULL DEFAULT 0,
request_errors_json LONGTEXT NULL,
vue_errors_json LONGTEXT NULL,
runtime_context_json LONGTEXT NULL,
data_collection_accepted TINYINT(1) NOT NULL DEFAULT 0,
data_collection_accepted_at DATETIME NOT NULL,
data_collection_policy_version VARCHAR(64) NOT NULL DEFAULT 'error-report-v1',
resolved_at DATETIME NULL,
resolved_by_user_id INT NULL,
resolution_note TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_error_reports_status_created (status, created_at),
INDEX idx_error_reports_reporter_user (reporter_user_id, created_at),
INDEX idx_error_reports_reporter_subuser (reporter_subuser_id, created_at),
INDEX idx_error_reports_customer (reporter_customer_number, reporter_customer_number_context),
INDEX idx_error_reports_trace (release_trace_id),
INDEX idx_error_reports_route (route_path)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
self::$initialized = true;
self::$tablesExist = true;
}
public static function tablesExist(): bool
{
if (self::$tablesExist !== null) {
return self::$tablesExist;
}
global $db;
$result = $db->query("SHOW TABLES LIKE 'error_reports'");
self::$tablesExist = $result !== false && $result->num_rows > 0;
return self::$tablesExist;
}
}
@@ -0,0 +1,567 @@
<?php
namespace classes;
use RuntimeException;
use Throwable;
class error_report_service
{
private const ANSWER_MAX_LENGTH = 4000;
private const NOTE_MAX_LENGTH = 2000;
private const SCREENSHOT_MAX_BYTES = 6_000_000;
private const JSON_MAX_LENGTH = 200_000;
private const STATUS_OPEN = 'open';
private const STATUS_RESOLVED = 'resolved';
private bool $schemaEnsured = false;
private error_report_store $store;
public function __construct(?error_report_store $store = null)
{
$this->store = $store ?? new error_report_store();
}
public static function redactPayload(mixed $value, int $depth = 0): mixed
{
if ($depth > 8) {
return '[depth-limit]';
}
if (is_array($value)) {
$redacted = [];
$index = 0;
foreach ($value as $key => $item) {
$index++;
if ($index > 80) {
$redacted['[truncated]'] = 'More than 80 keys omitted.';
break;
}
$keyString = (string)$key;
if (preg_match('/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn/i', $keyString) === 1) {
$redacted[$key] = '[redacted]';
continue;
}
$redacted[$key] = self::redactPayload($item, $depth + 1);
}
return $redacted;
}
if (is_object($value)) {
return self::redactPayload((array)$value, $depth + 1);
}
if (is_string($value) && strlen($value) > 4000) {
return substr($value, 0, 4000) . "\n... [truncated]";
}
return $value;
}
public static function decodeScreenshotDataUri(string $dataUri): array
{
if (!preg_match('/^data:(image\/(?:png|jpeg|webp));base64,([a-zA-Z0-9+\/=\r\n]+)$/', trim($dataUri), $matches)) {
throw new RuntimeException('Screenshot must be a PNG, JPEG, or WebP data URI.');
}
$contents = base64_decode(preg_replace('/\s+/', '', $matches[2]) ?? '', true);
if ($contents === false || $contents === '') {
throw new RuntimeException('Screenshot could not be decoded.');
}
if (strlen($contents) > self::SCREENSHOT_MAX_BYTES) {
throw new RuntimeException('Screenshot is too large.');
}
return [
'mime_type' => $matches[1],
'contents' => $contents,
'size_bytes' => strlen($contents),
];
}
public function createFromCurrentPrincipal(array $payload): array
{
$this->ensureSchema();
$principal = $this->resolvePrincipal();
$answers = $this->validatedAnswers($payload);
if (!$this->acceptedDataCollection($payload['data_collection_accepted'] ?? null)) {
throw new RuntimeException('Data collection acceptance is required.');
}
$screenshot = self::decodeScreenshotDataUri((string)($payload['screenshot'] ?? ''));
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
$context = is_array($payload['context'] ?? null) ? $payload['context'] : [];
$requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25);
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
$runtimeContext = $this->runtimeContext($payload, $context);
$this->execute(
"INSERT INTO error_reports (
status,
reporter_type,
reporter_user_id,
reporter_subuser_id,
reporter_customer_number,
reporter_customer_number_context,
reporter_name,
reporter_email,
route_path,
page_url,
release_trace_id,
frontend_version,
api_version,
screenshot_object_key,
screenshot_mime_type,
screenshot_size_bytes,
before_error,
expected,
actual,
request_error_count,
vue_error_count,
request_errors_json,
vue_errors_json,
runtime_context_json,
data_collection_accepted,
data_collection_accepted_at,
data_collection_policy_version
) VALUES (
'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, NOW(), ?
)",
'siiiisssssssssisssiissss',
[
$principal['type'],
$principal['user_id'],
$principal['subuser_id'],
$principal['customer_number'],
$principal['customer_number_context'],
$principal['name'],
$principal['email'],
$runtimeContext['route_path'],
$runtimeContext['page_url'],
$runtimeContext['release_trace_id'],
$runtimeContext['frontend_version'],
$runtimeContext['api_version'],
$storedScreenshot['key'],
$storedScreenshot['mime_type'],
(int)$storedScreenshot['size_bytes'],
$answers['before_error'],
$answers['expected'],
$answers['actual'],
count($requestErrors),
count($vueErrors),
$this->jsonEncodeLimited(self::redactPayload($requestErrors)),
$this->jsonEncodeLimited(self::redactPayload($vueErrors)),
$this->jsonEncodeLimited(self::redactPayload($runtimeContext)),
$runtimeContext['data_collection_policy_version'],
]
);
return $this->get($this->insertId());
}
public function list(array $filters = []): array
{
$this->ensureSchema();
$where = ['1 = 1'];
$types = '';
$params = [];
$status = $this->statusFilter($filters['status'] ?? self::STATUS_OPEN);
if ($status !== 'all') {
$where[] = 'status = ?';
$types .= 's';
$params[] = $status;
}
$search = trim((string)($filters['q'] ?? $filters['search'] ?? ''));
if ($search !== '') {
$where[] = '(route_path LIKE ? OR page_url LIKE ? OR before_error LIKE ? OR actual LIKE ? OR reporter_name LIKE ? OR reporter_email LIKE ?)';
$types .= 'ssssss';
$like = '%' . $search . '%';
array_push($params, $like, $like, $like, $like, $like, $like);
}
$limit = min(200, max(1, (int)($filters['limit'] ?? 50)));
$offset = max(0, (int)($filters['offset'] ?? 0));
$types .= 'ii';
$params[] = $limit;
$params[] = $offset;
$items = $this->selectRows(
"SELECT id, status, reporter_type, reporter_user_id, reporter_subuser_id,
reporter_customer_number, reporter_customer_number_context, reporter_name, reporter_email,
route_path, page_url, release_trace_id, frontend_version, api_version,
screenshot_mime_type, screenshot_size_bytes, before_error, expected, actual,
request_error_count, vue_error_count, resolved_at, resolved_by_user_id, created_at, updated_at
FROM error_reports
WHERE " . implode(' AND ', $where) . "
ORDER BY created_at DESC
LIMIT ? OFFSET ?",
$types,
$params
);
return [
'items' => array_map(fn(array $row): array => $this->publicReport($row, false), $items),
'counts' => $this->counts(),
'limit' => $limit,
'offset' => $offset,
];
}
public function get(int $id): array
{
$this->ensureSchema();
$row = $this->selectOne('SELECT * FROM error_reports WHERE id = ? LIMIT 1', 'i', [$id]);
if ($row === null) {
throw new RuntimeException('Error report not found.');
}
return $this->publicReport($row, true);
}
public function updateStatus(int $id, string $status, ?string $resolutionNote, ?int $actorUserId): array
{
$this->ensureSchema();
$status = self::normalizeStatus($status);
$note = $resolutionNote !== null ? $this->trimmedString($resolutionNote, self::NOTE_MAX_LENGTH, false) : null;
if ($status === self::STATUS_RESOLVED) {
$this->execute(
'UPDATE error_reports SET status = ?, resolved_at = NOW(), resolved_by_user_id = ?, resolution_note = ? WHERE id = ?',
'sisi',
[$status, $actorUserId, $note, $id]
);
} else {
$this->execute(
'UPDATE error_reports SET status = ?, resolved_at = NULL, resolved_by_user_id = NULL, resolution_note = ? WHERE id = ?',
'ssi',
[$status, $note, $id]
);
}
return $this->get($id);
}
public static function normalizeStatus(string $status): string
{
$status = strtolower(trim($status));
if (!in_array($status, [self::STATUS_OPEN, self::STATUS_RESOLVED], true)) {
throw new RuntimeException('Invalid error report status.');
}
return $status;
}
private function validatedAnswers(array $payload): array
{
return [
'before_error' => $this->requiredAnswer($payload, ['before_error', 'what_were_you_doing_before_error_occurred']),
'expected' => $this->requiredAnswer($payload, ['expected', 'what_did_you_expect_would_happen']),
'actual' => $this->requiredAnswer($payload, ['actual', 'what_actually_happened']),
];
}
private function requiredAnswer(array $payload, array $keys): string
{
foreach ($keys as $key) {
if (array_key_exists($key, $payload)) {
return $this->trimmedString((string)$payload[$key], self::ANSWER_MAX_LENGTH, true);
}
}
throw new RuntimeException('Missing required answer.');
}
private function trimmedString(string $value, int $maxLength, bool $required): string
{
$value = trim($value);
if ($required && $value === '') {
throw new RuntimeException('Required text fields must not be empty.');
}
if (strlen($value) > $maxLength) {
return substr($value, 0, $maxLength);
}
return $value;
}
private function acceptedDataCollection(mixed $value): bool
{
return $value === true || $value === 1 || $value === '1' || $value === 'true';
}
private function runtimeContext(array $payload, array $context): array
{
return [
'route_path' => $this->nullableString($payload['route_path'] ?? $context['route_path'] ?? $context['route'] ?? null, 512),
'page_url' => $this->nullableString($payload['page_url'] ?? $context['page_url'] ?? $context['url'] ?? null, 1024),
'release_trace_id' => $this->nullableString($payload['release_trace_id'] ?? $context['release_trace_id'] ?? $context['trace_id'] ?? $this->releaseRequestContext('trace_id'), 64),
'frontend_version' => $this->nullableString($payload['frontend_version'] ?? $context['frontend_version'] ?? $this->releaseRequestContext('frontend_version'), 128),
'api_version' => $this->nullableString($payload['api_version'] ?? $context['api_version'] ?? $this->releaseRequestContext('backend_version'), 128),
'viewport' => is_array($context['viewport'] ?? null) ? $context['viewport'] : null,
'user_agent' => $this->nullableString($context['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? null), 1024),
'captured_at' => $this->nullableString($context['captured_at'] ?? null, 64),
'data_collection_policy_version' => $this->nullableString($payload['data_collection_policy_version'] ?? $context['data_collection_policy_version'] ?? 'error-report-v1', 64) ?? 'error-report-v1',
];
}
private function releaseRequestContext(string $key): ?string
{
$context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null) ? $GLOBALS['RELEASE_REQUEST_CONTEXT'] : [];
return isset($context[$key]) ? (string)$context[$key] : null;
}
private function nullableString(mixed $value, int $maxLength): ?string
{
if ($value === null) {
return null;
}
$value = trim((string)$value);
if ($value === '') {
return null;
}
return substr($value, 0, $maxLength);
}
private function boundedArray(mixed $value, int $limit): array
{
return is_array($value) ? array_slice(array_values($value), 0, $limit) : [];
}
private function statusFilter(mixed $status): string
{
$status = strtolower(trim((string)$status));
if ($status === '' || $status === self::STATUS_OPEN) {
return self::STATUS_OPEN;
}
if ($status === self::STATUS_RESOLVED || $status === 'all') {
return $status;
}
return self::STATUS_OPEN;
}
private function counts(): array
{
$rows = $this->selectRows('SELECT status, COUNT(*) AS count FROM error_reports GROUP BY status');
$counts = [
self::STATUS_OPEN => 0,
self::STATUS_RESOLVED => 0,
'all' => 0,
];
foreach ($rows as $row) {
$status = (string)($row['status'] ?? '');
$count = (int)($row['count'] ?? 0);
if (isset($counts[$status])) {
$counts[$status] = $count;
}
$counts['all'] += $count;
}
return $counts;
}
private function resolvePrincipal(): array
{
$auth = new authentication();
try {
$subuser = $auth->get_subuser();
if ($subuser !== false) {
return [
'type' => 'subuser',
'user_id' => null,
'subuser_id' => (int)$subuser->id,
'customer_number' => null,
'customer_number_context' => $this->headerInt('X-Customer-Number'),
'name' => $this->safeObjectValue($subuser, 'name') ?: $this->safeObjectValue($subuser, 'username'),
'email' => $this->safeObjectValue($subuser, 'email'),
];
}
} catch (Throwable) {
}
try {
$user = $auth->get_user();
if ($user !== false) {
return [
'type' => 'user',
'user_id' => (int)$user->id,
'subuser_id' => null,
'customer_number' => isset($user->customer_number) ? (int)$user->customer_number->value() : null,
'customer_number_context' => null,
'name' => $this->safeObjectValue($user, 'display_name'),
'email' => $this->safeObjectValue($user, 'email'),
];
}
} catch (Throwable) {
}
throw new RuntimeException('Authentication failed. Invalid or missing token.');
}
private function headerInt(string $name): ?int
{
$headers = function_exists('getallheaders') ? getallheaders() : [];
foreach ($headers as $key => $value) {
if (strcasecmp((string)$key, $name) === 0) {
$int = (int)$value;
return $int > 0 ? $int : null;
}
}
$serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
$int = (int)($_SERVER[$serverKey] ?? 0);
return $int > 0 ? $int : null;
}
private function safeObjectValue(object $object, string $property): ?string
{
try {
if (!isset($object->{$property}) || !method_exists($object->{$property}, 'value')) {
return null;
}
$value = $object->{$property}->value();
return $value === null ? null : substr((string)$value, 0, 255);
} catch (Throwable) {
return null;
}
}
private function publicReport(array $row, bool $includeDetail): array
{
$report = [
'id' => (int)$row['id'],
'status' => (string)$row['status'],
'reporter' => [
'type' => $row['reporter_type'] ?? null,
'user_id' => isset($row['reporter_user_id']) ? (int)$row['reporter_user_id'] : null,
'subuser_id' => isset($row['reporter_subuser_id']) ? (int)$row['reporter_subuser_id'] : null,
'customer_number' => isset($row['reporter_customer_number']) ? (int)$row['reporter_customer_number'] : null,
'customer_number_context' => isset($row['reporter_customer_number_context']) ? (int)$row['reporter_customer_number_context'] : null,
'name' => $row['reporter_name'] ?? null,
'email' => $row['reporter_email'] ?? null,
],
'route_path' => $row['route_path'] ?? null,
'page_url' => $row['page_url'] ?? null,
'release_trace_id' => $row['release_trace_id'] ?? null,
'frontend_version' => $row['frontend_version'] ?? null,
'api_version' => $row['api_version'] ?? null,
'screenshot' => [
'mime_type' => $row['screenshot_mime_type'] ?? null,
'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0,
],
'answers' => [
'before_error' => $row['before_error'] ?? '',
'expected' => $row['expected'] ?? '',
'actual' => $row['actual'] ?? '',
],
'request_error_count' => isset($row['request_error_count']) ? (int)$row['request_error_count'] : 0,
'vue_error_count' => isset($row['vue_error_count']) ? (int)$row['vue_error_count'] : 0,
'resolved_at' => $row['resolved_at'] ?? null,
'resolved_by_user_id' => isset($row['resolved_by_user_id']) ? (int)$row['resolved_by_user_id'] : null,
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
];
if ($includeDetail) {
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? ''));
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null;
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
$report['data_collection'] = [
'accepted' => (bool)($row['data_collection_accepted'] ?? false),
'accepted_at' => $row['data_collection_accepted_at'] ?? null,
'policy_version' => $row['data_collection_policy_version'] ?? null,
];
$report['resolution_note'] = $row['resolution_note'] ?? null;
}
return $report;
}
private function ensureSchema(): void
{
if ($this->schemaEnsured) {
return;
}
error_report_schema_bootstrap::ensureTables();
$this->schemaEnsured = true;
}
private function selectOne(string $sql, string $types = '', array $params = []): ?array
{
$rows = $this->selectRows($sql, $types, $params);
return $rows[0] ?? null;
}
private function selectRows(string $sql, string $types = '', array $params = []): array
{
global $db;
if ($types === '') {
$result = $db->query($sql);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
$stmt = $db->prepare($sql);
if ($stmt === false) {
throw new RuntimeException('Could not prepare error report query.');
}
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
private function execute(string $sql, string $types = '', array $params = []): void
{
global $db;
if ($types === '') {
$db->query($sql);
return;
}
$stmt = $db->prepare($sql);
if ($stmt === false) {
throw new RuntimeException('Could not prepare error report statement.');
}
$stmt->bind_param($types, ...$params);
$stmt->execute();
}
private function insertId(): int
{
global $db;
return (int)$db->insert_id();
}
private function jsonEncodeLimited(mixed $value): string
{
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Could not encode error report JSON payload.');
}
if (strlen($json) <= self::JSON_MAX_LENGTH) {
return $json;
}
$truncated = [
'[truncated]' => 'Payload exceeded ' . self::JSON_MAX_LENGTH . ' bytes.',
'preview' => substr($json, 0, self::JSON_MAX_LENGTH),
];
$encoded = json_encode($truncated, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return $encoded === false ? '{}' : $encoded;
}
private function jsonDecode(mixed $value): array
{
if (!is_string($value) || trim($value) === '') {
return [];
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
}
@@ -0,0 +1,47 @@
<?php
namespace classes;
use traits\minio_t;
class error_report_store
{
use minio_t;
public function __construct()
{
self::setBucket('uploads');
}
public function storeScreenshot(string $mimeType, string $contents): array
{
$extension = match ($mimeType) {
'image/webp' => 'webp',
'image/jpeg' => 'jpg',
default => 'png',
};
$datePath = date('Y/m');
$key = sprintf('error-reports/%s/%s.%s', $datePath, bin2hex(random_bytes(16)), $extension);
if (!self::createObject($key, $contents)) {
throw new \RuntimeException('Could not store error report screenshot.');
}
return [
'key' => $key,
'mime_type' => $mimeType,
'size_bytes' => strlen($contents),
];
}
public function screenshotUrl(string $key): ?string
{
$key = trim($key);
if ($key === '') {
return null;
}
return self::getPresignedUrl($key, 1200, false);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace classes;
require_once WD . '/modules/failover/failover_c.php';
use failover\failover_c;
class failover
{
public failover_c $config;
public function __construct()
{
$this->config = new failover_c();
}
}
+1 -16
View File
@@ -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');
}
}
}
@@ -0,0 +1,151 @@
<?php
namespace classes;
use RuntimeException;
class hetzner_cloud_api_exception extends RuntimeException
{
public function __construct(
string $message,
private readonly int $statusCode = 0,
private readonly string $apiCode = ''
) {
parent::__construct($message, $statusCode);
}
public function statusCode(): int
{
return $this->statusCode;
}
public function apiCode(): string
{
return $this->apiCode;
}
}
class hetzner_cloud_client
{
private const BASE_URL = 'https://api.hetzner.cloud/v1';
public function __construct(private readonly string $token, private readonly int $timeoutSeconds = 8)
{
if (trim($token) === '') {
throw new RuntimeException('Hetzner Cloud API token is required.');
}
}
public function getLoadBalancer(int|string $id): array
{
return $this->request('GET', '/load_balancers/' . rawurlencode((string)$id))['load_balancer'] ?? [];
}
public function addIpTarget(int|string $loadBalancerId, string $ip): array
{
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/add_target', [
'type' => 'ip',
'ip' => ['ip' => $ip],
]);
}
public function removeIpTarget(int|string $loadBalancerId, string $ip): array
{
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/remove_target', [
'type' => 'ip',
'ip' => ['ip' => $ip],
]);
}
public function addService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array
{
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/add_service', self::servicePayload(
$protocol,
$listenPort,
$destinationPort,
$options
));
}
public function updateService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array
{
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/update_service', self::servicePayload(
$protocol,
$listenPort,
$destinationPort,
$options
));
}
private static function servicePayload(string $protocol, int $listenPort, int $destinationPort, array $options): array
{
$payload = [
'protocol' => strtolower($protocol),
'listen_port' => $listenPort,
'destination_port' => $destinationPort,
'proxyprotocol' => false,
];
foreach (['health_check', 'http'] as $key) {
if (isset($options[$key]) && is_array($options[$key])) {
$payload[$key] = $options[$key];
}
}
return $payload;
}
private function request(string $method, string $path, ?array $payload = null): array
{
$curl = curl_init(self::BASE_URL . $path);
if ($curl === false) {
throw new RuntimeException('Could not initialize Hetzner Cloud API request.');
}
$headers = [
'Accept: application/json',
'Authorization: Bearer ' . trim($this->token),
];
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method));
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, min(3, $this->timeoutSeconds));
curl_setopt($curl, CURLOPT_TIMEOUT, max(1, $this->timeoutSeconds));
curl_setopt($curl, CURLOPT_NOSIGNAL, true);
curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
if ($payload !== null) {
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($body === false) {
throw new RuntimeException('Could not encode Hetzner Cloud API payload.');
}
$headers[] = 'Content-Type: application/json';
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
}
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
$raw = curl_exec($curl);
$error = curl_error($curl);
$status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($raw === false) {
throw new RuntimeException('Hetzner Cloud API request failed: ' . $error);
}
$decoded = trim((string)$raw) === '' ? [] : json_decode((string)$raw, true);
if (!is_array($decoded)) {
$decoded = ['raw' => (string)$raw];
}
if ($status < 200 || $status >= 300) {
$errorPayload = is_array($decoded['error'] ?? null) ? $decoded['error'] : [];
$apiCode = (string)($errorPayload['code'] ?? $decoded['code'] ?? '');
$message = (string)($errorPayload['message'] ?? $decoded['message'] ?? ('HTTP ' . $status));
throw new hetzner_cloud_api_exception('Hetzner Cloud API request failed: ' . $message, $status, $apiCode);
}
return $decoded;
}
}
@@ -0,0 +1,64 @@
<?php
namespace classes;
/**
* Ensures additive schema for superuser invoice-period flags.
*/
class invoice_period_flag_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"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;
}
}
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -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);
}
}
}
+49 -2
View File
@@ -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;
}
}
}
@@ -0,0 +1,517 @@
<?php
namespace classes;
use PDO;
class order_reference_suggestions_service
{
private const DEFAULT_LIMIT = 10;
private const MAX_LIMIT = 25;
private const MAX_SOURCE_ROWS = 500;
/**
* @var array<string, bool>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, string> $plates
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $params
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $rows
* @param array<int, string> $plates
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $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<int, string> $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<int, string> $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<int, string> $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<int, mixed> $values
* @return array<int, string>
*/
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;
}
}
@@ -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);
@@ -0,0 +1,68 @@
<?php
namespace classes;
/**
* Ensures additive schema for product metadata used outside the core product form.
*/
class products_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
if (!self::tableExists($db, 'products')) {
return;
}
if (!self::columnExists($db, 'products', 'max_quantity_per_order')) {
$db->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);
}
}
+176
View File
@@ -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);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,557 @@
<?php
namespace classes;
/**
* Additive schema bootstrap for application release management.
*
* The legacy API does not have a centralized migration runner, so these
* migrations must be safe to call from request handlers, tests, and cron.
*/
class release_manager_schema_bootstrap
{
private static bool $initialized = false;
private static ?bool $tablesExist = null;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
$queries = [
"CREATE TABLE IF NOT EXISTS release_channels (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
description TEXT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
default_channel TINYINT(1) NOT NULL DEFAULT 0,
rollout_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
frontend_base_url VARCHAR(512) NULL,
api_base_url VARCHAR(512) NULL,
replay_enabled TINYINT(1) NOT NULL DEFAULT 0,
capture_level VARCHAR(32) NOT NULL DEFAULT 'metadata',
retention_days INT NOT NULL DEFAULT 14,
metadata_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
UNIQUE KEY uq_release_channels_slug (slug),
INDEX idx_release_channels_enabled (enabled, deleted_at),
INDEX idx_release_channels_default (default_channel, deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_versions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
app VARCHAR(16) NOT NULL,
repository VARCHAR(255) NULL,
branch VARCHAR(128) NULL,
commit_sha VARCHAR(64) NULL,
tag VARCHAR(128) NULL,
version_label VARCHAR(128) NULL,
build_url VARCHAR(512) NULL,
artifact_url VARCHAR(512) NULL,
deployed_url VARCHAR(512) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'discovered',
metadata_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deployed_at DATETIME NULL,
INDEX idx_release_versions_app_status (app, status),
INDEX idx_release_versions_commit (commit_sha),
INDEX idx_release_versions_repo_branch (repository, branch)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_channel_versions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
frontend_version_id BIGINT UNSIGNED NULL,
api_version_id BIGINT UNSIGNED NULL,
deployment_id BIGINT UNSIGNED NULL,
service_set_id BIGINT UNSIGNED NULL,
bundle_id BIGINT UNSIGNED NULL,
actor_user_id INT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
activated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_release_channel_versions_channel_active (channel_id, active),
INDEX idx_release_channel_versions_frontend (frontend_version_id),
INDEX idx_release_channel_versions_api (api_version_id),
INDEX idx_release_channel_versions_deployment (deployment_id),
INDEX idx_release_channel_versions_service_set (service_set_id),
INDEX idx_release_channel_versions_bundle (bundle_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_assignments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
subject_type VARCHAR(16) NOT NULL,
subject_id VARCHAR(64) NOT NULL,
channel_id BIGINT UNSIGNED NOT NULL,
reason VARCHAR(255) NULL,
expires_at DATETIME NULL,
actor_user_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
INDEX idx_release_assignments_subject (subject_type, subject_id, deleted_at, expires_at),
INDEX idx_release_assignments_channel (channel_id, deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_deployment_targets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
app VARCHAR(16) NOT NULL,
coolify_instance_id BIGINT UNSIGNED NULL,
coolify_service_uuid VARCHAR(128) NULL,
repository VARCHAR(255) NOT NULL,
branch VARCHAR(128) NOT NULL DEFAULT 'main',
auto_deploy TINYINT(1) NOT NULL DEFAULT 1,
health_url VARCHAR(512) NULL,
deploy_context_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
INDEX idx_release_targets_channel_app (channel_id, app, deleted_at),
INDEX idx_release_targets_repo_branch (repository, branch, auto_deploy, deleted_at),
INDEX idx_release_targets_coolify (coolify_instance_id, coolify_service_uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_auto_sync_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
app VARCHAR(16) NOT NULL,
repository VARCHAR(255) NOT NULL,
branch VARCHAR(128) NOT NULL,
commit_sha VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
source VARCHAR(64) NULL,
workflow_url VARCHAR(512) NULL,
gate_operation_id BIGINT UNSIGNED NULL,
sync_operation_id BIGINT UNSIGNED NULL,
deployment_id BIGINT UNSIGNED NULL,
error_message TEXT NULL,
metadata_json LONGTEXT NULL,
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
gate_passed_at DATETIME NULL,
synced_at DATETIME NULL,
promoted_at DATETIME NULL,
failed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_release_auto_sync_event (channel_id, app, repository, branch, commit_sha),
INDEX idx_release_auto_sync_channel_status (channel_id, status, updated_at),
INDEX idx_release_auto_sync_gate (gate_operation_id),
INDEX idx_release_auto_sync_sync (sync_operation_id),
INDEX idx_release_auto_sync_deployment (deployment_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_service_sets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NULL,
name VARCHAR(128) NOT NULL,
slug VARCHAR(64) NOT NULL,
mode VARCHAR(32) NOT NULL DEFAULT 'attach_existing',
source_service_set_id BIGINT UNSIGNED NULL,
frontend_target_id BIGINT UNSIGNED NULL,
api_target_id BIGINT UNSIGNED NULL,
database_coolify_target_id BIGINT UNSIGNED NULL,
redis_coolify_target_id BIGINT UNSIGNED NULL,
minio_coolify_target_id BIGINT UNSIGNED NULL,
status VARCHAR(32) NOT NULL DEFAULT 'needs_configuration',
health_json LONGTEXT NULL,
metadata_json LONGTEXT NULL,
actor_user_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
UNIQUE KEY uq_release_service_sets_slug (slug),
INDEX idx_release_service_sets_channel (channel_id, deleted_at),
INDEX idx_release_service_sets_source (source_service_set_id),
INDEX idx_release_service_sets_status (status, deleted_at),
INDEX idx_release_service_sets_frontend_target (frontend_target_id),
INDEX idx_release_service_sets_api_target (api_target_id),
INDEX idx_release_service_sets_database_target (database_coolify_target_id),
INDEX idx_release_service_sets_redis_target (redis_coolify_target_id),
INDEX idx_release_service_sets_minio_target (minio_coolify_target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_deployments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
target_id BIGINT UNSIGNED NULL,
version_id BIGINT UNSIGNED NULL,
service_set_id BIGINT UNSIGNED NULL,
bundle_id BIGINT UNSIGNED NULL,
deployment_kind VARCHAR(32) NOT NULL DEFAULT 'single_app',
app VARCHAR(16) NOT NULL,
active_channel_app_key VARCHAR(96) NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'coolify',
repository VARCHAR(255) NULL,
branch VARCHAR(128) NULL,
commit_sha VARCHAR(64) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
provider_operation_id VARCHAR(128) NULL,
deployment_url VARCHAR(512) NULL,
actor_user_id INT NULL,
requested_payload_json LONGTEXT NULL,
result_json LONGTEXT NULL,
error_message TEXT NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_release_deployments_channel_status (channel_id, status),
INDEX idx_release_deployments_target (target_id),
INDEX idx_release_deployments_version (version_id),
INDEX idx_release_deployments_service_set (service_set_id),
INDEX idx_release_deployments_bundle (bundle_id),
INDEX idx_release_deployments_kind (deployment_kind),
INDEX idx_release_deployments_commit (commit_sha)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_bundles (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
service_set_id BIGINT UNSIGNED NOT NULL,
version_label VARCHAR(128) NULL,
frontend_version_id BIGINT UNSIGNED NULL,
api_version_id BIGINT UNSIGNED NULL,
frontend_deployment_id BIGINT UNSIGNED NULL,
api_deployment_id BIGINT UNSIGNED NULL,
frontend_repository VARCHAR(255) NULL,
frontend_branch VARCHAR(128) NULL,
frontend_commit_sha VARCHAR(64) NULL,
api_repository VARCHAR(255) NULL,
api_branch VARCHAR(128) NULL,
api_commit_sha VARCHAR(64) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'draft',
deployment_result_json LONGTEXT NULL,
metadata_json LONGTEXT NULL,
actor_user_id INT NULL,
deployed_at DATETIME NULL,
promoted_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
INDEX idx_release_bundles_channel_status (channel_id, status, deleted_at),
INDEX idx_release_bundles_service_set (service_set_id, status, deleted_at),
INDEX idx_release_bundles_frontend_version (frontend_version_id),
INDEX idx_release_bundles_api_version (api_version_id),
INDEX idx_release_bundles_frontend_deployment (frontend_deployment_id),
INDEX idx_release_bundles_api_deployment (api_deployment_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_replay_targets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
target_type VARCHAR(16) NOT NULL,
target_id VARCHAR(64) NULL,
channel_id BIGINT UNSIGNED NULL,
capture_level VARCHAR(32) NOT NULL DEFAULT 'full_redacted',
enabled TINYINT(1) NOT NULL DEFAULT 1,
expires_at DATETIME NULL,
actor_user_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
INDEX idx_release_replay_target (target_type, target_id, enabled, deleted_at, expires_at),
INDEX idx_release_replay_channel (channel_id, enabled, deleted_at, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_timeline_sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
trace_id VARCHAR(64) NOT NULL,
session_hash VARCHAR(64) NULL,
principal_type VARCHAR(16) NULL,
principal_id VARCHAR(64) NULL,
customer_number INT NULL,
channel_id BIGINT UNSIGNED NULL,
channel_slug VARCHAR(64) NULL,
frontend_version_id BIGINT UNSIGNED NULL,
api_version_id BIGINT UNSIGNED NULL,
device_type VARCHAR(16) NULL,
browser_name VARCHAR(64) NULL,
browser_version VARCHAR(64) NULL,
os_name VARCHAR(64) NULL,
os_version VARCHAR(64) NULL,
viewport_width INT NULL,
viewport_height INT NULL,
device_pixel_ratio DECIMAL(6,3) NULL,
frontend_version_label VARCHAR(128) NULL,
frontend_commit_sha VARCHAR(128) NULL,
api_version_label VARCHAR(128) NULL,
api_commit_sha VARCHAR(128) NULL,
last_route_path VARCHAR(255) NULL,
user_agent VARCHAR(512) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_release_timeline_trace (trace_id),
INDEX idx_release_timeline_principal (principal_type, principal_id),
INDEX idx_release_timeline_channel (channel_id, channel_slug),
INDEX idx_release_timeline_device (device_type),
INDEX idx_release_timeline_release (frontend_version_label, api_version_label),
INDEX idx_release_timeline_customer (customer_number),
INDEX idx_release_timeline_last_seen (last_seen_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_timeline_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
timeline_session_id BIGINT UNSIGNED NULL,
trace_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
severity VARCHAR(16) NOT NULL DEFAULT 'info',
module_key VARCHAR(64) NULL,
route_path VARCHAR(255) NULL,
component VARCHAR(255) NULL,
request_id VARCHAR(128) NULL,
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
payload_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_release_timeline_events_session (timeline_session_id, occurred_at),
INDEX idx_release_timeline_events_trace (trace_id, occurred_at),
INDEX idx_release_timeline_events_type (event_type, severity),
INDEX idx_release_timeline_events_module (module_key, occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_module_health_snapshots (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NULL,
module_key VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
reason VARCHAR(512) NULL,
payload_json LONGTEXT NULL,
checked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_release_module_health_module (module_key, checked_at),
INDEX idx_release_module_health_channel (channel_id, module_key, checked_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_operation_runs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
operation_type VARCHAR(64) NOT NULL,
subject_type VARCHAR(64) NULL,
subject_id VARCHAR(128) NULL,
channel_id BIGINT UNSIGNED NULL,
app VARCHAR(16) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
title VARCHAR(255) NULL,
summary TEXT NULL,
solution_hint TEXT NULL,
actor_user_id INT NULL,
context_json LONGTEXT NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_release_operation_runs_channel (channel_id, created_at),
INDEX idx_release_operation_runs_subject (subject_type, subject_id, created_at),
INDEX idx_release_operation_runs_type_status (operation_type, status),
INDEX idx_release_operation_runs_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_operation_steps (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
operation_run_id BIGINT UNSIGNED NOT NULL,
step_key VARCHAR(64) NOT NULL,
label VARCHAR(255) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
message TEXT NULL,
diagnostic TEXT NULL,
solution_hint TEXT NULL,
context_json LONGTEXT NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_release_operation_steps_run (operation_run_id, id),
INDEX idx_release_operation_steps_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NULL,
deployment_id BIGINT UNSIGNED NULL,
action VARCHAR(64) NOT NULL,
actor_user_id INT NULL,
severity VARCHAR(16) NOT NULL DEFAULT 'info',
context_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_release_audit_channel (channel_id, created_at),
INDEX idx_release_audit_deployment (deployment_id),
INDEX idx_release_audit_action (action)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $sql) {
$db->query($sql);
}
self::ensureColumn('release_channel_versions', 'service_set_id', 'BIGINT UNSIGNED NULL AFTER deployment_id');
self::ensureColumn('release_channel_versions', 'bundle_id', 'BIGINT UNSIGNED NULL AFTER service_set_id');
self::ensureColumn('release_deployments', 'service_set_id', 'BIGINT UNSIGNED NULL AFTER version_id');
self::ensureColumn('release_deployments', 'bundle_id', 'BIGINT UNSIGNED NULL AFTER service_set_id');
self::ensureColumn('release_deployments', 'deployment_kind', "VARCHAR(32) NOT NULL DEFAULT 'single_app' AFTER bundle_id");
self::ensureColumn('release_deployments', 'active_channel_app_key', 'VARCHAR(96) NULL AFTER app');
self::ensureColumn('release_timeline_sessions', 'device_type', 'VARCHAR(16) NULL AFTER api_version_id');
self::ensureColumn('release_timeline_sessions', 'browser_name', 'VARCHAR(64) NULL AFTER device_type');
self::ensureColumn('release_timeline_sessions', 'browser_version', 'VARCHAR(64) NULL AFTER browser_name');
self::ensureColumn('release_timeline_sessions', 'os_name', 'VARCHAR(64) NULL AFTER browser_version');
self::ensureColumn('release_timeline_sessions', 'os_version', 'VARCHAR(64) NULL AFTER os_name');
self::ensureColumn('release_timeline_sessions', 'viewport_width', 'INT NULL AFTER os_version');
self::ensureColumn('release_timeline_sessions', 'viewport_height', 'INT NULL AFTER viewport_width');
self::ensureColumn('release_timeline_sessions', 'device_pixel_ratio', 'DECIMAL(6,3) NULL AFTER viewport_height');
self::ensureColumn('release_timeline_sessions', 'frontend_version_label', 'VARCHAR(128) NULL AFTER device_pixel_ratio');
self::ensureColumn('release_timeline_sessions', 'frontend_commit_sha', 'VARCHAR(128) NULL AFTER frontend_version_label');
self::ensureColumn('release_timeline_sessions', 'api_version_label', 'VARCHAR(128) NULL AFTER frontend_commit_sha');
self::ensureColumn('release_timeline_sessions', 'api_commit_sha', 'VARCHAR(128) NULL AFTER api_version_label');
self::ensureColumn('release_timeline_sessions', 'last_route_path', 'VARCHAR(255) NULL AFTER api_commit_sha');
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_device', 'device_type');
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_release', 'frontend_version_label, api_version_label');
self::ensureUniqueIndex('release_deployments', 'uniq_release_deployments_active_channel_app', 'active_channel_app_key');
self::ensureModuleConfigDefault('ReleaseManager', 'enabled', 'true', 'bool');
self::ensureModuleConfigDefault('ReleaseManager', 'github_webhook_secret', '', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'release_gate_token', '', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'release_gate_required_for_promotion', 'true', 'bool');
self::ensureModuleConfigDefault('ReleaseManager', 'github_token', '', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'github_api_url', 'https://api.github.com', 'string');
self::ensureModuleConfigDefault('ReleaseManager', 'default_retention_days', '14', 'int');
self::ensureDefaultChannels();
self::$initialized = true;
self::$tablesExist = true;
}
public static function tablesExist(): bool
{
if (self::$tablesExist !== null) {
return self::$tablesExist;
}
global $db;
foreach ([
'release_channels',
'release_versions',
'release_channel_versions',
'release_assignments',
'release_auto_sync_events',
'release_service_sets',
'release_deployments',
'release_bundles',
'release_operation_runs',
'release_operation_steps',
'release_timeline_sessions',
'release_timeline_events',
] as $table) {
$tableSql = $db->escape_string($table);
$result = $db->query("SHOW TABLES LIKE '$tableSql'");
if ($result === false || $result->num_rows === 0) {
self::$tablesExist = false;
return false;
}
}
self::$tablesExist = true;
return true;
}
private static function ensureDefaultChannels(): void
{
global $db;
$channels = [
['stable', 'Stable', 'Default production channel.', 1, 1, 'metadata'],
['canary', 'Canary', 'Earliest production validation channel.', 1, 0, 'metadata'],
['beta', 'Beta', 'Broader pre-stable rollout channel.', 1, 0, 'metadata'],
['internal', 'Internal', 'Internal staff and superuser validation channel.', 1, 0, 'metadata'],
];
foreach ($channels as [$slug, $name, $description, $enabled, $default, $captureLevel]) {
$db->query(sprintf(
"INSERT IGNORE INTO release_channels (slug, name, description, enabled, default_channel, capture_level)
VALUES ('%s', '%s', '%s', %d, %d, '%s')",
$db->escape_string($slug),
$db->escape_string($name),
$db->escape_string($description),
(int)$enabled,
(int)$default,
$db->escape_string($captureLevel)
));
}
}
private static function ensureModuleConfigDefault(string $module, string $variable, string $value, string $type): void
{
global $db;
$moduleSql = $db->escape_string($module);
$variableSql = $db->escape_string($variable);
$result = $db->query("SELECT value FROM module_config WHERE module = '$moduleSql' AND variable = '$variableSql' LIMIT 1");
if ($result !== false && $result->num_rows > 0) {
return;
}
$valueSql = $db->escape_string($value);
$typeSql = $db->escape_string($type);
$db->query("INSERT INTO module_config (module, variable, value, type) VALUES ('$moduleSql', '$variableSql', '$valueSql', '$typeSql')");
}
private static function ensureColumn(string $table, string $column, string $definition): void
{
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
if ($table === '' || $column === '') {
return;
}
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
if ($result !== false && $result->num_rows > 0) {
return;
}
$db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
}
private static function ensureIndex(string $table, string $index, string $columns): void
{
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$index = preg_replace('/[^a-zA-Z0-9_]/', '', $index);
if ($table === '' || $index === '') {
return;
}
$indexSql = $db->escape_string($index);
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'");
if ($result !== false && $result->num_rows > 0) {
return;
}
$db->query("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
}
private static function ensureUniqueIndex(string $table, string $index, string $columns): void
{
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$index = preg_replace('/[^a-zA-Z0-9_]/', '', $index);
if ($table === '' || $index === '') {
return;
}
$indexSql = $db->escape_string($index);
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'");
if ($result !== false && $result->num_rows > 0) {
return;
}
$db->query("ALTER TABLE `$table` ADD UNIQUE KEY `$index` ($columns)");
}
}
@@ -0,0 +1,19 @@
<?php
namespace classes;
class releasemanager
{
public function isEnabled(): bool
{
try {
release_manager_schema_bootstrap::ensureTables();
global $db;
$result = $db->query("SELECT value FROM module_config WHERE module = 'ReleaseManager' AND variable = 'enabled' LIMIT 1");
$row = $result ? $result->fetch_assoc() : null;
return in_array(strtolower(trim((string)($row['value'] ?? 'true'))), ['1', 'true', 'yes', 'on'], true);
} catch (\Throwable) {
return true;
}
}
}
@@ -0,0 +1,521 @@
<?php
namespace classes;
use Aws\S3\S3Client;
use mysqli;
use Predis\Client as PredisClient;
use Throwable;
class replica_failover_manager
{
public const KIND_DATABASE = 'database';
public const KIND_REDIS = 'redis';
public const KIND_MINIO = 'minio';
public const DEFAULT_MAX_STATUS_AGE_SECONDS = 90;
public static function configDefaults(): array
{
return [
'enabled' => false,
'database_enabled' => false,
'redis_enabled' => false,
'minio_enabled' => false,
'max_status_age_seconds' => self::DEFAULT_MAX_STATUS_AGE_SECONDS,
];
}
public static function normalizeConfig(array $config): array
{
$normalized = self::configDefaults();
foreach (['enabled', 'database_enabled', 'redis_enabled', 'minio_enabled'] as $key) {
if (array_key_exists($key, $config)) {
$normalized[$key] = self::boolValue($config[$key]);
}
}
if (array_key_exists('max_status_age_seconds', $config)) {
$normalized['max_status_age_seconds'] = max(1, (int)$config['max_status_age_seconds']);
}
return $normalized;
}
public static function kindEnabled(array $config, string $kind): bool
{
$config = self::normalizeConfig($config);
return $config['enabled'] && !empty($config[$kind . '_enabled']);
}
public static function snapshotHostIsStrictlyFresh(array $host, int $maxAgeSeconds, ?int $now = null): bool
{
if (($host['role'] ?? '') !== 'replica') {
return false;
}
if (!empty($host['deleted_at'])) {
return false;
}
$status = self::hostStatus($host);
if (($status['status'] ?? '') !== 'ok') {
return false;
}
if (round((float)($status['replication_percent'] ?? 0), 2) < 100.0) {
return false;
}
$blockers = $status['blockers'] ?? [];
if (is_array($blockers) && $blockers !== []) {
return false;
}
$checkedAt = self::hostCheckedAt($host, $status);
if ($checkedAt === null) {
return false;
}
return (($now ?? time()) - $checkedAt) <= max(1, $maxAgeSeconds);
}
public static function snapshotFailoverCandidate(array $hosts, string $kind, int $maxAgeSeconds, ?int $now = null): ?array
{
$eligible = array_values(array_filter(
$hosts,
static fn(array $host): bool => ($host['kind'] ?? '') === $kind
&& self::snapshotHostIsStrictlyFresh($host, $maxAgeSeconds, $now)
));
if ($eligible === []) {
return null;
}
usort($eligible, static function (array $a, array $b) use ($now): int {
$aChecked = self::hostCheckedAt($a, self::hostStatus($a)) ?? 0;
$bChecked = self::hostCheckedAt($b, self::hostStatus($b)) ?? 0;
if ($aChecked === $bChecked) {
return (int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0);
}
return $bChecked <=> $aChecked;
});
return $eligible[0];
}
public static function activeConfigFromHost(string $kind, array $host): ?array
{
if ($kind === self::KIND_DATABASE) {
$database = trim((string)($host['database_name'] ?? $host['database'] ?? ''));
$user = trim((string)($host['username'] ?? $host['user'] ?? ''));
if ($database === '' || $user === '') {
return null;
}
return [
'id' => isset($host['id']) ? (int)$host['id'] : null,
'host' => (string)($host['host'] ?? ''),
'port' => (int)($host['port'] ?? 3306) ?: 3306,
'database' => $database,
'user' => $user,
'password_secret' => (string)($host['password_secret'] ?? ''),
'ssl_mode' => (string)($host['ssl_mode'] ?? 'DISABLED'),
];
}
if ($kind === self::KIND_REDIS) {
return [
'id' => isset($host['id']) ? (int)$host['id'] : null,
'host' => (string)($host['host'] ?? ''),
'port' => (int)($host['port'] ?? 6379) ?: 6379,
'database' => (int)($host['database_index'] ?? $host['database'] ?? 0),
'user' => (string)($host['username'] ?? $host['user'] ?? ''),
'password_secret' => (string)($host['password_secret'] ?? ''),
];
}
if ($kind === self::KIND_MINIO) {
$options = self::jsonDecode($host['options_json'] ?? null);
return [
'id' => isset($host['id']) ? (int)$host['id'] : null,
'endpoint' => self::minioEndpoint($host, $options),
'access_key' => (string)($host['username'] ?? $host['access_key'] ?? ''),
'secret_key_secret' => (string)($host['password_secret'] ?? ''),
'buckets' => is_array($options['buckets'] ?? null) ? array_values($options['buckets']) : [],
];
}
return null;
}
public static function applyStartupFailoverFromSnapshot(?string $path = null, array $probes = []): array
{
$snapshot = replication_bootstrap_config::loadSnapshot($path);
$failover = is_array($snapshot['failover'] ?? null) ? $snapshot['failover'] : [];
$config = self::normalizeConfig(is_array($failover['config'] ?? null) ? $failover['config'] : $failover);
$active = is_array($snapshot['active'] ?? null) ? $snapshot['active'] : [];
$hostGroups = is_array($failover['hosts'] ?? null) ? $failover['hosts'] : [];
$maxAgeSeconds = (int)$config['max_status_age_seconds'];
$summary = [];
$changed = false;
$primaryDown = $probes['primary_down'] ?? [self::class, 'activePrimaryIsDown'];
$candidateReachable = $probes['candidate_reachable'] ?? [self::class, 'candidateReachable'];
$promoteCandidate = $probes['promote_candidate'] ?? [self::class, 'promoteCandidate'];
foreach ([self::KIND_DATABASE, self::KIND_REDIS, self::KIND_MINIO] as $kind) {
if (!self::kindEnabled($config, $kind)) {
$summary[$kind] = ['status' => 'skipped', 'reason' => 'disabled'];
continue;
}
if (!is_array($active[$kind] ?? null)) {
$summary[$kind] = ['status' => 'skipped', 'reason' => 'missing_active_primary'];
continue;
}
try {
if (!call_user_func($primaryDown, $kind, $active[$kind], $snapshot)) {
$summary[$kind] = ['status' => 'skipped', 'reason' => 'primary_healthy'];
continue;
}
$hosts = is_array($hostGroups[$kind] ?? null) ? $hostGroups[$kind] : [];
$candidate = self::snapshotFailoverCandidate($hosts, $kind, $maxAgeSeconds);
if ($candidate === null) {
$summary[$kind] = ['status' => 'skipped', 'reason' => 'no_fresh_caught_up_replica'];
continue;
}
if (!call_user_func($candidateReachable, $kind, $candidate)) {
$summary[$kind] = [
'status' => 'skipped',
'reason' => 'candidate_unreachable',
'candidate_id' => (int)($candidate['id'] ?? 0),
];
continue;
}
call_user_func($promoteCandidate, $kind, $candidate);
$candidateActive = self::activeConfigFromHost($kind, $candidate);
if ($candidateActive === null) {
$summary[$kind] = [
'status' => 'skipped',
'reason' => 'candidate_missing_active_config',
'candidate_id' => (int)($candidate['id'] ?? 0),
];
continue;
}
$snapshot['active'][$kind] = $candidateActive;
$pending = is_array($snapshot['pending_failovers'] ?? null) ? $snapshot['pending_failovers'] : [];
$pending[] = [
'kind' => $kind,
'host_id' => (int)($candidate['id'] ?? 0),
'label' => (string)($candidate['label'] ?? ''),
'source' => 'startup_snapshot',
'promoted_at' => date('c'),
];
$snapshot['pending_failovers'] = $pending;
$summary[$kind] = [
'status' => 'promoted',
'candidate_id' => (int)($candidate['id'] ?? 0),
];
$changed = true;
} catch (Throwable $throwable) {
$summary[$kind] = [
'status' => 'failed',
'reason' => $throwable->getMessage(),
];
}
}
if ($changed) {
$snapshot['generated_at'] = date('c');
replication_bootstrap_config::writeSnapshot($snapshot, $path);
if ($path === null) {
replication_bootstrap_config::applyToGlobals($snapshot);
}
}
return [
'changed' => $changed,
'results' => $summary,
];
}
public static function activePrimaryIsDown(string $kind, array $activeConfig, array $snapshot = []): bool
{
try {
match ($kind) {
self::KIND_DATABASE => self::probeActiveDatabase($activeConfig),
self::KIND_REDIS => self::probeActiveRedis($activeConfig),
self::KIND_MINIO => self::probeActiveMinio($activeConfig),
default => null,
};
return false;
} catch (Throwable) {
return true;
}
}
public static function candidateReachable(string $kind, array $host): bool
{
try {
match ($kind) {
self::KIND_DATABASE => self::probeHostDatabase($host),
self::KIND_REDIS => self::probeHostRedis($host),
self::KIND_MINIO => self::probeHostMinio($host),
default => null,
};
return true;
} catch (Throwable) {
return false;
}
}
public static function promoteCandidate(string $kind, array $host): void
{
match ($kind) {
self::KIND_DATABASE => self::promoteDatabaseCandidate($host),
self::KIND_REDIS => self::promoteRedisCandidate($host),
self::KIND_MINIO => self::probeHostMinio($host),
default => null,
};
}
private static function probeActiveDatabase(array $config): void
{
$host = (string)($config['host'] ?? '');
$user = (string)($config['user'] ?? '');
$database = (string)($config['database'] ?? '');
$password = self::activePassword($config, 'password_secret', 'password');
self::connectMysqli($host, $user, $password, $database, (int)($config['port'] ?? 3306))->close();
}
private static function probeHostDatabase(array $host): void
{
$credentials = self::hostCredentials($host);
$connection = self::connectMysqli(
(string)($host['host'] ?? ''),
$credentials['username'],
$credentials['password'],
(string)($host['database_name'] ?? ''),
(int)($host['port'] ?? 3306)
);
$connection->close();
}
private static function promoteDatabaseCandidate(array $host): void
{
$credentials = self::hostCredentials($host);
$user = $credentials['admin_username'] !== '' ? $credentials['admin_username'] : $credentials['username'];
$password = $credentials['admin_password'] !== '' ? $credentials['admin_password'] : $credentials['password'];
$connection = self::connectMysqli(
(string)($host['host'] ?? ''),
$user,
$password,
(string)($host['database_name'] ?? ''),
(int)($host['port'] ?? 3306)
);
try {
foreach (['STOP REPLICA', 'STOP SLAVE'] as $statement) {
try {
$connection->query($statement);
break;
} catch (Throwable) {
}
}
foreach (['SET GLOBAL super_read_only = OFF', 'SET GLOBAL read_only = OFF'] as $statement) {
try {
$connection->query($statement);
} catch (Throwable) {
}
}
} finally {
$connection->close();
}
}
private static function probeActiveRedis(array $config): void
{
self::redisClientFromConfig([
'host' => (string)($config['host'] ?? ''),
'port' => (int)($config['port'] ?? 6379),
'database' => (int)($config['database'] ?? 0),
'user' => (string)($config['user'] ?? ''),
'password' => self::activePassword($config, 'password_secret', 'password'),
])->ping();
}
private static function probeHostRedis(array $host): void
{
self::redisClientFromHost($host)->ping();
}
private static function promoteRedisCandidate(array $host): void
{
$client = self::redisClientFromHost($host);
$client->executeRaw(['REPLICAOF', 'NO', 'ONE']);
try {
$client->executeRaw(['CONFIG', 'REWRITE']);
} catch (Throwable) {
}
}
private static function probeActiveMinio(array $config): void
{
self::minioClientFromConfig([
'endpoint' => (string)($config['endpoint'] ?? ''),
'access_key' => (string)($config['access_key'] ?? $config['user'] ?? ''),
'secret_key' => self::activePassword($config, 'secret_key_secret', 'secret_key'),
])->listBuckets();
}
private static function probeHostMinio(array $host): void
{
self::minioClientFromHost($host)->listBuckets();
}
private static function connectMysqli(string $host, string $user, string $password, string $database, int $port): mysqli
{
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$connection = mysqli_init();
$connection->options(MYSQLI_OPT_CONNECT_TIMEOUT, 2);
$connection->real_connect($host, $user, $password, $database, $port ?: 3306);
$connection->set_charset('utf8mb4');
return $connection;
}
private static function redisClientFromHost(array $host): PredisClient
{
$credentials = self::hostCredentials($host);
return self::redisClientFromConfig([
'host' => (string)($host['host'] ?? ''),
'port' => (int)($host['port'] ?? 6379),
'database' => (int)($host['database_index'] ?? 0),
'user' => $credentials['username'],
'password' => $credentials['password'],
]);
}
private static function redisClientFromConfig(array $config): PredisClient
{
$params = [
'scheme' => 'tcp',
'host' => (string)$config['host'],
'port' => (int)$config['port'],
'database' => (int)$config['database'],
'password' => (string)$config['password'],
'timeout' => 2.0,
'read_write_timeout' => 2.0,
];
if (($config['user'] ?? '') !== '' && $config['user'] !== 'default') {
$params['username'] = (string)$config['user'];
}
return new PredisClient($params);
}
private static function minioClientFromHost(array $host): S3Client
{
$credentials = self::hostCredentials($host);
$options = self::jsonDecode($host['options_json'] ?? null);
return self::minioClientFromConfig([
'endpoint' => self::minioEndpoint($host, $options),
'access_key' => $credentials['username'],
'secret_key' => $credentials['password'],
]);
}
private static function minioClientFromConfig(array $config): S3Client
{
return new S3Client([
'version' => 'latest',
'region' => 'us-east-1',
'endpoint' => (string)$config['endpoint'],
'use_path_style_endpoint' => true,
'credentials' => [
'key' => (string)$config['access_key'],
'secret' => (string)$config['secret_key'],
],
'http' => [
'connect_timeout' => 2,
'timeout' => 2,
],
]);
}
private static function minioEndpoint(array $host, array $options): string
{
$endpoint = trim((string)($options['endpoint'] ?? ''));
if ($endpoint !== '') {
return $endpoint;
}
$scheme = strtolower(trim((string)($options['scheme'] ?? 'http')));
if ($scheme !== 'https') {
$scheme = 'http';
}
return $scheme . '://' . (string)($host['host'] ?? '') . ':' . ((int)($host['port'] ?? 9000) ?: 9000);
}
private static function hostCredentials(array $host): array
{
return [
'username' => (string)($host['username'] ?? ''),
'password' => replication_secret_box::decrypt($host['password_secret'] ?? ''),
'admin_username' => (string)($host['admin_username'] ?? ''),
'admin_password' => replication_secret_box::decrypt($host['admin_password_secret'] ?? ''),
];
}
private static function activePassword(array $config, string $secretKey, string $plainKey): string
{
if (!empty($config[$secretKey])) {
return replication_secret_box::decrypt((string)$config[$secretKey]);
}
return (string)($config[$plainKey] ?? '');
}
private static function hostStatus(array $host): array
{
if (isset($host['last_status']) && is_array($host['last_status'])) {
return $host['last_status'];
}
return self::jsonDecode($host['last_status_json'] ?? null);
}
private static function hostCheckedAt(array $host, array $status): ?int
{
$raw = $host['last_checked_at'] ?? $status['checked_at'] ?? null;
if (!is_string($raw) || trim($raw) === '') {
return null;
}
$timestamp = strtotime($raw);
return $timestamp === false ? null : $timestamp;
}
private static function boolValue(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
}
private static function jsonDecode(mixed $value): array
{
if (!is_string($value) || trim($value) === '') {
return [];
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
}
@@ -0,0 +1,156 @@
<?php
namespace classes;
use RuntimeException;
use Throwable;
class replication_bootstrap_config
{
public static function snapshotPath(): string
{
return self::storageDir() . DIRECTORY_SEPARATOR . 'replication-bootstrap.json';
}
public static function loadSnapshot(?string $path = null): array
{
$path = $path ?: self::snapshotPath();
if (!is_file($path) || !is_readable($path)) {
return [];
}
$decoded = json_decode((string)file_get_contents($path), true);
return is_array($decoded) ? $decoded : [];
}
public static function writeSnapshot(array $snapshot, ?string $path = null): void
{
$path = $path ?: self::snapshotPath();
$dir = dirname($path);
if (!is_dir($dir) && !mkdir($dir, 0770, true) && !is_dir($dir)) {
throw new RuntimeException('Could not create replication bootstrap snapshot directory.');
}
$snapshot = array_replace([
'version' => 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';
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,122 @@
<?php
namespace classes;
class replication_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
$queries = [
"CREATE TABLE IF NOT EXISTS replication_hosts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
kind VARCHAR(16) NOT NULL,
label VARCHAR(128) NOT NULL,
host VARCHAR(255) NOT NULL,
port INT UNSIGNED NOT NULL,
database_name VARCHAR(128) NULL,
database_index INT NULL,
username VARCHAR(128) NULL,
password_secret TEXT NULL,
admin_username VARCHAR(128) NULL,
admin_password_secret TEXT NULL,
replication_username VARCHAR(128) NULL,
replication_password_secret TEXT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'replica',
status VARCHAR(16) NOT NULL DEFAULT 'unknown',
replication_source_id BIGINT UNSIGNED NULL,
ssl_mode VARCHAR(32) NULL,
options_json LONGTEXT NULL,
last_status_json LONGTEXT NULL,
last_checked_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
INDEX idx_replication_hosts_kind_role (kind, role),
INDEX idx_replication_hosts_kind_status (kind, status),
INDEX idx_replication_hosts_source (replication_source_id),
INDEX idx_replication_hosts_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS replication_operations (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
kind VARCHAR(16) NOT NULL,
host_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL,
progress_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
message VARCHAR(512) NULL,
error_message TEXT NULL,
context_json LONGTEXT NULL,
actor_user_id INT NULL,
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_replication_operations_host (host_id),
INDEX idx_replication_operations_kind_status (kind, status),
INDEX idx_replication_operations_operation (operation)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS replication_status_snapshots (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
host_id BIGINT UNSIGNED NOT NULL,
kind VARCHAR(16) NOT NULL,
status VARCHAR(16) NOT NULL,
replication_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
lag_seconds INT NULL,
blockers_json LONGTEXT NULL,
raw_status_json LONGTEXT NULL,
checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_replication_status_host_checked (host_id, checked_at),
INDEX idx_replication_status_kind_checked (kind, checked_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS replication_audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
kind VARCHAR(16) NOT NULL,
host_id BIGINT UNSIGNED NULL,
action VARCHAR(64) NOT NULL,
actor_user_id INT NULL,
severity VARCHAR(16) NOT NULL DEFAULT 'info',
context_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_replication_audit_kind_host (kind, host_id),
INDEX idx_replication_audit_action (action),
INDEX idx_replication_audit_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $sql) {
$db->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");
}
}
@@ -0,0 +1,102 @@
<?php
namespace classes;
use RuntimeException;
class replication_secret_box
{
private const PREFIX = 'twsec:v1:';
private const CIPHER = 'aes-256-gcm';
public static function encrypt(string $plaintext): string
{
$nonce = random_bytes(12);
$tag = '';
$ciphertext = openssl_encrypt(
$plaintext,
self::CIPHER,
self::key(),
OPENSSL_RAW_DATA,
$nonce,
$tag,
'',
16
);
if ($ciphertext === false || $tag === '') {
throw new RuntimeException('Secret encryption failed.');
}
return self::PREFIX . base64_encode(json_encode([
'nonce' => 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);
}
}
+42 -55
View File
@@ -14,6 +14,7 @@ class response implements response_i
private array $meta = [];
private array $includes = [];
private users_o $users_o;
private ?array $jsonRequestBody = null;
#[NoReturn] public function success(mixed $data, int $status = null): void
{
@@ -47,6 +48,11 @@ class response implements response_i
'data' => $this->get_data()
]);
}
try {
release_manager::recordBackendFailure($success, $data, $status ?? ($success ? 200 : 400));
} catch (\Throwable) {
// Release failure telemetry is best-effort and must not block responses.
}
echo json_encode([
'success' => $success,
'data' => $data,
@@ -175,35 +181,7 @@ class response implements response_i
public function getRequestParameter(string $key): mixed
{
$data = [];
// Get the request data if the method is POST, PUT or PATCH
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
$data = json_decode(file_get_contents('php://input'), true);
}
// Get the request data if the method is GET, DELETE or OPTIONS
if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
$data = $_GET;
}
if (!is_array($data)) {
$data = [];
}
// If the data key is not set, try to get it from the opposite method
if (!array_key_exists($key, $data)) {
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
$data = $_GET;
} else {
$data = json_decode(file_get_contents('php://input'), true);
}
}
if (!is_array($data)) {
$data = [];
}
// Return the data
return $data[$key] ?? null;
return $this->requestParametersForMethod()[$key] ?? null;
}
/**
@@ -212,21 +190,7 @@ class response implements response_i
*/
public function getAllRequestParameters(): array
{
$data = [];
// Get the request data if the method is POST, PUT or PATCH
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
$data = json_decode(file_get_contents('php://input'), true);
}
// Get the request data if the method is GET, DELETE or OPTIONS
if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
$data = $_GET;
}
if (!is_array($data)) {
return [];
}
return $data;
return $this->requestParametersForMethod();
}
/**
@@ -237,21 +201,44 @@ class response implements response_i
*/
public function isRequestParameterSet(string $key): bool
{
$data = [];
// Get the request data if the method is POST, PUT or PATCH
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
$data = json_decode(file_get_contents('php://input'), true);
}
// Get the request data if the method is GET, DELETE or OPTIONS
if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
$data = $_GET;
return array_key_exists($key, $this->requestParametersForMethod());
}
private function requestParametersForMethod(): array
{
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if (in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
return array_replace($_GET, $this->jsonRequestBody());
}
if (!is_array($data)) {
return false;
if ($method === 'DELETE') {
return array_replace($_GET, $this->jsonRequestBody());
}
return array_key_exists($key, $data);
if ($method === 'GET' || $method === 'OPTIONS') {
return $_GET;
}
return $this->jsonRequestBody();
}
private function jsonRequestBody(): array
{
if ($this->jsonRequestBody !== null) {
return $this->jsonRequestBody;
}
$decoded = json_decode($this->rawRequestBody(), true);
$this->jsonRequestBody = is_array($decoded) ? $decoded : [];
return $this->jsonRequestBody;
}
protected function rawRequestBody(): string
{
$body = file_get_contents('php://input');
return is_string($body) ? $body : '';
}
public function parseFilters(?string $filters): array|null
@@ -126,6 +126,41 @@ class selfserve_schema_bootstrap
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) {
@@ -137,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',
@@ -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'] ?? ''));
@@ -0,0 +1,103 @@
<?php
namespace classes;
final class workfeed_employee_name_formatter
{
/**
* @param array<int,string> $firstNamePaths
* @param array<int,string> $lastNamePaths
* @param array<int,string> $fallbackNamePaths
*/
public static function fromRecord(
mixed $record,
array $firstNamePaths,
array $lastNamePaths,
array $fallbackNamePaths = [],
?string $employeeId = null
): ?string {
$firstName = self::firstTextValueByPath($record, $firstNamePaths);
$lastName = self::firstTextValueByPath($record, $lastNamePaths);
$schemaName = self::joinNameParts($firstName, $lastName);
if ($schemaName !== null) {
return $schemaName;
}
foreach ($fallbackNamePaths as $path) {
$name = self::normalizeTextValue(self::valueByPath($record, $path));
if ($name !== null && !self::isMissingDisplayName($name, $employeeId)) {
return $name;
}
}
return null;
}
public static function isMissingDisplayName(?string $employeeName, ?string $employeeId = null): bool
{
if ($employeeName === null) {
return true;
}
if ($employeeId !== null && strcasecmp($employeeName, 'Employee ' . $employeeId) === 0) {
return true;
}
return strcasecmp($employeeName, 'Unknown employee') === 0;
}
/**
* @param array<int,string> $paths
*/
private static function firstTextValueByPath(mixed $record, array $paths): ?string
{
foreach ($paths as $path) {
$value = self::normalizeTextValue(self::valueByPath($record, $path));
if ($value !== null) {
return $value;
}
}
return null;
}
private static function joinNameParts(?string $firstName, ?string $lastName): ?string
{
$name = trim((string)($firstName ?? '') . ' ' . (string)($lastName ?? ''));
return $name !== '' ? $name : null;
}
private static function valueByPath(mixed $record, string $path): mixed
{
$segments = explode('.', $path);
$value = $record;
foreach ($segments as $segment) {
if (is_array($value) && array_key_exists($segment, $value)) {
$value = $value[$segment];
continue;
}
if (is_object($value) && isset($value->{$segment})) {
$value = $value->{$segment};
continue;
}
return null;
}
return $value;
}
private static function normalizeTextValue(mixed $value): ?string
{
if (!is_scalar($value)) {
return null;
}
$normalized = trim((string)$value);
return $normalized !== '' ? $normalized : null;
}
}
@@ -21,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,
];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,145 @@
<?php
namespace classes;
/**
* Ensures additive schema for XL Vask usage-log review state.
*/
class xlvask_usage_logs_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
if (!self::tableExists($db, 'xlvask_usage_logs')) {
return;
}
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_at', 'DATETIME NULL AFTER WashItems');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_by', 'INT NULL AFTER ignored_at');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_reason', 'TEXT NULL AFTER ignored_by');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_total_net_amount', 'DECIMAL(12,2) NULL AFTER ignored_reason');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_primary_product_name', 'VARCHAR(255) NULL AFTER cached_total_net_amount');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_amount_at', 'DATETIME NULL AFTER cached_primary_product_name');
self::ensureAutomationTables($db);
self::$initialized = true;
}
private static function ensureAutomationTables(object $db): void
{
$db->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);
}
}
+25 -1
View File
@@ -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": {
+3 -3
View File
@@ -7530,7 +7530,7 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
@@ -7539,6 +7539,6 @@
"ext-curl": "*",
"ext-json": "*"
},
"platform-dev": [],
"plugin-api-version": "2.6.0"
"platform-dev": {},
"plugin-api-version": "2.9.0"
}
+11
View File
@@ -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());
}
+384 -14
View File
@@ -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<int,mixed>
*/
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<int,string>
*/
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<int,array<string,mixed>> $taskRows
* @return array<int,array{dynamic_image_id:int,buttons:array<int>|null,current_step:int,only_current_step:bool,vehicle_type:int|null}>
* @return array<int,array{dynamic_image_id:int,buttons:array<int|string>|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<int>|null $buttons
* @param array<int|string>|null $buttons
*/
function buildDynamicImageCacheKey(array $variant): string
{
@@ -575,7 +941,7 @@ function buildDynamicImageCacheKey(array $variant): string
}
/**
* @param array<int>|null $buttons
* @param array<int|string>|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<int>
* @return array<int|string>
*/
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<int> $base
* @param array<int> $append
* @return array<int>
* @param array<int|string> $base
* @param array<int|string> $append
* @return array<int|string>
*/
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);
+53 -19
View File
@@ -8,30 +8,20 @@ ini_set('zlib.output_compression', false);
*/
const WD = __DIR__;
require_once __DIR__ . '/vendor/autoload.php';
require_once 'config.php';
require_once __DIR__ . '/classes/cors_policy.php';
/** CORS */
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$allowed_origins = array_map('trim', explode(',', (string)($CORS ?? '*')));
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
header("Access-Control-Allow-Credentials: true");
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, *");
header("Access-Control-Allow-Methods: GET, POST, PUT, 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'])) {
+106
View File
@@ -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<int,array{dateFrom:string,dateTo:string}>
*/
public function consume_invoice_period_warming_queue(): array;
}
@@ -5,6 +5,7 @@ namespace attachments\helpers;
class attachment_content
{
const OTHER_TYPE_WASH_CERTIFICATE = 'WASH_CERTIFICATE';
const OTHER_TYPE_SELF_SERVE_WASH = 'SELF_SERVE_WASH';
public ?string $image; // Used to store the attachment object name, in the attachment store.
public ?string $document; // Used to store the attachment object name, in the attachment store.
public ?attachment_relation $relation; // Used to store the attachment relation object.
@@ -49,4 +50,4 @@ class attachment_content
$this->relation = $relation;
return $this;
}
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_enabled_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'enabled',
'bool',
false,
null,
'Enable Coolify-managed replicated infrastructure targets.',
'true',
false,
'false'
);
}
}
@@ -0,0 +1,38 @@
<?php
namespace modules\coolify\config;
use classes\replication_secret_box;
use traits\module_config_variable;
class coolify_hetzner_cloud_api_token_c
{
use module_config_variable {
setVariableValue as private traitSetVariableValue;
}
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'hetzner_cloud_api_token',
'string',
false,
null,
'Hetzner Cloud API token with Load Balancer read/write permissions.',
'pat_...',
true,
''
);
}
public function setVariableValue(mixed $value): void
{
$value = trim((string)($value ?? ''));
if ($value !== '' && !str_starts_with($value, 'twsec:v1:')) {
$value = replication_secret_box::encrypt($value);
}
$this->traitSetVariableValue($value);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_hetzner_load_balancer_id_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'hetzner_load_balancer_id',
'string',
false,
null,
'Hetzner Cloud Load Balancer ID used as the public Coolify gateway.',
'1234567',
false,
''
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_lb_automation_enabled_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'lb_automation_enabled',
'bool',
false,
null,
'Enable automated Hetzner Load Balancer reconciliation for the Coolify public gateway.',
'false',
false,
'false'
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_lb_automation_mode_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'lb_automation_mode',
'string',
false,
['report_only', 'enforce'],
'Controls whether Hetzner Load Balancer reconciliation reports drift only or applies changes.',
'report_only',
false,
'report_only'
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_public_gateway_host_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'public_gateway_host',
'string',
false,
null,
'Public DNS hostname served by the replicated Coolify gateway Load Balancer.',
'api-v2.truckwash.io',
false,
'api-v2.truckwash.io'
);
}
}
@@ -0,0 +1,63 @@
<?php
namespace modules\coolify;
require_once WD . '/modules/coolify/config/coolify_enabled_c.php';
require_once WD . '/modules/coolify/config/coolify_hetzner_cloud_api_token_c.php';
require_once WD . '/modules/coolify/config/coolify_hetzner_load_balancer_id_c.php';
require_once WD . '/modules/coolify/config/coolify_lb_automation_enabled_c.php';
require_once WD . '/modules/coolify/config/coolify_lb_automation_mode_c.php';
require_once WD . '/modules/coolify/config/coolify_public_gateway_host_c.php';
use modules\coolify\config\coolify_hetzner_cloud_api_token_c;
use modules\coolify\config\coolify_hetzner_load_balancer_id_c;
use modules\coolify\config\coolify_enabled_c;
use modules\coolify\config\coolify_lb_automation_enabled_c;
use modules\coolify\config\coolify_lb_automation_mode_c;
use modules\coolify\config\coolify_public_gateway_host_c;
use traits\module_config_t;
class coolify_c
{
use module_config_t {
getConfigRequest as private traitGetConfigRequest;
}
public coolify_enabled_c $enabled;
public coolify_lb_automation_enabled_c $lb_automation_enabled;
public coolify_lb_automation_mode_c $lb_automation_mode;
public coolify_hetzner_load_balancer_id_c $hetzner_load_balancer_id;
public coolify_hetzner_cloud_api_token_c $hetzner_cloud_api_token;
public coolify_public_gateway_host_c $public_gateway_host;
public function __construct()
{
$this->setupConfig('Coolify');
$this->allowUpdate([
coolify_enabled_c::class,
coolify_lb_automation_enabled_c::class,
coolify_lb_automation_mode_c::class,
coolify_hetzner_load_balancer_id_c::class,
coolify_hetzner_cloud_api_token_c::class,
coolify_public_gateway_host_c::class,
]);
$this->enabled = new coolify_enabled_c();
$this->lb_automation_enabled = new coolify_lb_automation_enabled_c();
$this->lb_automation_mode = new coolify_lb_automation_mode_c();
$this->hetzner_load_balancer_id = new coolify_hetzner_load_balancer_id_c();
$this->hetzner_cloud_api_token = new coolify_hetzner_cloud_api_token_c();
$this->public_gateway_host = new coolify_public_gateway_host_c();
}
public function getConfigRequest(): array
{
return array_map(static function (array $row): array {
if (($row['variable'] ?? '') === 'hetzner_cloud_api_token') {
$secretSet = trim((string)($row['value'] ?? '')) !== '';
$row['value'] = $secretSet ? '[redacted]' : '';
$row['secret_set'] = $secretSet;
}
return $row;
}, $this->traitGetConfigRequest());
}
}
@@ -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<int,array{asset: dynamicimages_asset, x: int, y: int}>
* @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<int,array{asset: dynamicimages_asset, x: int, y: int}> $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();
}
}
}
}
@@ -0,0 +1,28 @@
<?php
namespace config;
use traits\module_config_variable;
class economic_default_department_id_c
{
use module_config_variable;
/**
* @throws \Exception
*/
public function __construct()
{
self::setupConfigVariable(
'economic',
'defaultDepartmentId',
'int',
false,
null,
'Fallback department id used when customers have no standard department for invoicing distribution',
'1',
false,
1
);
}
}
@@ -5,12 +5,14 @@ require_once WD . '/modules/economic/config/economic_admin_fee_monthly_c.php';
require_once WD . '/modules/economic/config/economic_admin_fee_order_c.php';
require_once WD . '/modules/economic/config/economic_fee_product_id_c.php';
require_once WD . '/modules/economic/config/economic_transaction_draft_customer_number_c.php';
require_once WD . '/modules/economic/config/economic_default_department_id_c.php';
use config\economic_invoice_layout_c;
use config\economic_payment_terms_c;
use config\economic_admin_fee_monthly_c;
use config\economic_admin_fee_order_c;
use config\economic_fee_product_id_c;
use config\economic_default_department_id_c;
use config\economic_transaction_draft_customer_number_c;
use traits\module_config_t;
@@ -23,6 +25,7 @@ class economic_c
public economic_admin_fee_monthly_c $admin_fee_monthly;
public economic_admin_fee_order_c $admin_fee_order;
public economic_fee_product_id_c $fee_product_id;
public economic_default_department_id_c $default_department_id;
public economic_transaction_draft_customer_number_c $transaction_draft_customer_number;
public function __construct()
@@ -34,6 +37,7 @@ class economic_c
economic_admin_fee_monthly_c::class,
economic_admin_fee_order_c::class,
economic_fee_product_id_c::class,
economic_default_department_id_c::class,
economic_transaction_draft_customer_number_c::class,
]);
$this->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();
}
}
@@ -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,
@@ -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',
@@ -364,6 +364,7 @@ class edge_gateway_manager
}
$gatewayPayload = $this->getGateway($gatewayId);
$gatewayPayload['broker_url'] = $this->buildBrokerPublicUrl();
edge_gateway_view_cache::syncGateway($gatewayPayload);
return $gatewayPayload;
@@ -4020,7 +4021,7 @@ BASH;
{
$configured = $this->configuredBrokerSharedSecret();
if ($configured === '') {
return true;
return false;
}
return $secret !== null && hash_equals($configured, trim($secret));
@@ -21,9 +21,9 @@ class edgegateway_public_broker_url_c
false,
null,
'The browser-routable edge broker URL, including the proxy path prefix.',
'https://api.truckwash.io:4433/edge-broker',
'https://api-v2.truckwash.io/edge-broker',
false,
trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: ''))
trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: '')) ?: 'https://api-v2.truckwash.io/edge-broker'
);
}
}
@@ -41,7 +41,6 @@ class edgegateway_c
edgegateway_broker_url_c::class,
edgegateway_public_broker_url_c::class,
edgegateway_broker_auth_mode_c::class,
edgegateway_broker_shared_secret_c::class,
]);
$this->enabled = new edgegateway_enabled_c();
$this->default_release_channel = new edgegateway_default_release_channel_c();
@@ -39,7 +39,15 @@ class edgeGatewayConfigRoute
}
(new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully fetched edge gateway config');
$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
@@ -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;
@@ -0,0 +1,29 @@
<?php
namespace failover\config;
use Exception;
use traits\module_config_variable;
class failover_database_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Failover',
'database_enabled',
'bool',
true,
null,
'Whether automatic database replica failover is enabled.',
'false',
false,
'false'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace failover\config;
use Exception;
use traits\module_config_variable;
class failover_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Failover',
'enabled',
'bool',
true,
null,
'Whether automatic failover is enabled.',
'false',
false,
'false'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace failover\config;
use Exception;
use traits\module_config_variable;
class failover_max_status_age_seconds_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Failover',
'max_status_age_seconds',
'int',
true,
null,
'Maximum age in seconds for a stored replica status to be eligible for automatic failover.',
'90',
false,
'90'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace failover\config;
use Exception;
use traits\module_config_variable;
class failover_minio_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Failover',
'minio_enabled',
'bool',
true,
null,
'Whether automatic MinIO replica failover is enabled.',
'false',
false,
'false'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace failover\config;
use Exception;
use traits\module_config_variable;
class failover_redis_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Failover',
'redis_enabled',
'bool',
true,
null,
'Whether automatic Redis replica failover is enabled.',
'false',
false,
'false'
);
}
}

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