Compare commits

..
Author SHA1 Message Date
Jeppe Bundgaard ce43c4e064 Expose limited backoffice role permission templates 2026-07-07 03:23:42 +02:00
Jeppe B 579ddcf510 Merge pull request #307 from copenhagentruckwash/copilot/update-limited-backoffice-roles
Fix limited-backoffice role permissions and enforce department access on order mutations
2026-07-07 02:53:06 +02:00
Jeppe Bundgaard 0b342a7780 Align limited backoffice permission cap tests 2026-07-07 02:47:49 +02:00
copilot-swe-agent[bot] 57bcbaf72a Fix 11 failing API tests across 4 files 2026-07-07 00:31:43 +00:00
copilot-swe-agent[bot] d9fbba3130 Return 404 when order item not found in DELETE /order/items 2026-07-06 23:19:00 +00:00
copilot-swe-agent[bot] e4465d9d91 Improve DELETE /order/items: clearer error message, 404 when order not found 2026-07-06 23:17:50 +00:00
copilot-swe-agent[bot] 734cd13c87 Handle prepared statement failure with error response in DELETE /order/items 2026-07-06 23:16:55 +00:00
copilot-swe-agent[bot] d0f94ac549 Use prepared statements for all new DB queries in tests and route 2026-07-06 23:15:59 +00:00
copilot-swe-agent[bot] 1d25cbe21c Fix SQL injection concerns: use prepared statements in orderItemsRoute and tests 2026-07-06 23:14:34 +00:00
copilot-swe-agent[bot] 53d0636193 Fix limited-backoffice permissions and add department access restrictions 2026-07-06 23:12:01 +00:00
Jeppe B 04bb26f1b0 Merge pull request #306 from copenhagentruckwash/copilot/fix-php-api-job-failure
Fix two failing LimitedBackofficeApiTest assertions
2026-07-07 00:44:53 +02:00
copilot-swe-agent[bot] df0d4783d0 Remove search_customers and search_vehicles from raw-permission exclusion list 2026-07-06 22:37:40 +00:00
copilot-swe-agent[bot] 39c06ceab6 Address code review: restore filter passthrough and add clarifying comments 2026-07-06 22:28:45 +00:00
copilot-swe-agent[bot] 7dd428d18e Fix two failing LimitedBackofficeApiTest tests
Fix 1: Remove add_order_attachments and download_order_attachments from
the raw-permissions exclusion check in the test. These strings are valid
capability names that legitimately appear in the /limited-backoffice/roles
response, so including them in the 'should not contain' list caused a
false failure.

Fix 2: Update limitedBackofficeEmployeeListMode() in usersRoute.php to
exclude active limited backoffice employees when include_limited_backoffice_employees
is not set and the customer_number:0 filter is in use. Previously the
method returned additional_where:null in this case, so limited employees
were included in the result set alongside regular backoffice employees.
2026-07-06 22:27:45 +00:00
copilot-swe-agent[bot] 0103a40156 Initial plan 2026-07-06 22:19:38 +00:00
Jeppe B e208b1b2a4 Merge pull request #305 from copenhagentruckwash/codex/limited-backoffice-employee-migration
Add limited backoffice employee migration
2026-07-07 00:14:25 +02:00
Jeppe Bundgaard 6b4b55cb62 Add limited backoffice employee migration 2026-07-07 00:10:22 +02:00
Jeppe Bundgaard 0cca597fdc Fix XLVask usage import dates
Fix XLVask usage-log import metadata and period-scoped Selvvask automation.
2026-07-06 23:49:28 +02:00
Jeppe B 709c6acbba Fix product null department permissions
Treats null-like optional product query params as omitted and avoids department_access_0 permission checks.
2026-07-06 20:14:45 +02:00
Jeppe Bundgaard ed2736e528 Fix product null department permissions 2026-07-06 19:56:07 +02:00
Jeppe B c7f5c73a9e Merge pull request #303 from copenhagentruckwash/codex/daily-report-product-targets-api
[codex] Add daily report product target API
2026-07-06 19:35:37 +02:00
Jeppe Bundgaard c10af48954 Add daily report product target API 2026-07-06 18:52:24 +02:00
Jeppe Bundgaard 7ac5c5585b Add limited backoffice employee QR login links 2026-07-06 17:32:58 +02:00
Jeppe B 8544ce0a18 Merge pull request #297 from copenhagentruckwash/codex/customer-product-fixed-price-overrides
Add customer product fixed price overrides
2026-07-06 17:23:06 +02:00
Jeppe Bundgaard 614715822f Fix backend merge fallout for booking and limited employees 2026-07-06 17:16:22 +02:00
Jeppe Bundgaard 1da02e2486 Fix limited backoffice price save reset 2026-07-06 17:13:04 +02:00
Jeppe B 742b15116d Merge pull request #295 from copenhagentruckwash/fix/economic-ean-transfer
Fix e-conomic EAN customer transfer
2026-07-06 17:00:57 +02:00
Jeppe Bundgaard e0ae74bdc2 Merge remote-tracking branch 'origin/master' into codex/customer-product-fixed-price-overrides 2026-07-06 17:00:52 +02:00
Jeppe Bundgaard 08dc803b3e Make limited backoffice employees regular employees 2026-07-06 16:59:32 +02:00
Jeppe Bundgaard 248a901f24 Merge master into fixed price override branch 2026-07-06 16:54:01 +02:00
Jeppe Bundgaard 8bbdf9daf5 Require booking add node for subuser booking creation 2026-07-06 16:48:07 +02:00
Jeppe B c089186046 Merge pull request #302 from copenhagentruckwash/codex/customer-orderbooking-create-without-permission
Allow customer order booking creation without booking permission
2026-07-06 16:39:17 +02:00
Jeppe Bundgaard 2ae1fc3fcf Allow customer order booking creation without booking permission 2026-07-06 16:33:31 +02:00
Jeppe Bundgaard d9eacf6f84 Deduplicate limited backoffice price products 2026-07-06 16:28:08 +02:00
Jeppe B f262047476 Merge pull request #300 from copenhagentruckwash/codex/scoped-monthly-split-api
Scope monthly invoice split API
2026-07-06 16:01:54 +02:00
Jeppe B b8390ac0d3 Merge pull request #298 from copenhagentruckwash/codex/only-tankcleaning-order-enforcement
Enforce only tankcleaning order products
2026-07-06 16:01:40 +02:00
Jeppe B 0d4a5470e5 Add superuser department overview API (#301)
Merge backend API for the superuser department overview.
2026-07-06 16:01:04 +02:00
Jeppe B 845ca6e48e Merge pull request #290 from copenhagentruckwash/codex/custom-pricing-only-departments
Add custom-only department pricing enforcement
2026-07-06 15:31:27 +02:00
Jeppe Bundgaard 1cda2a81aa Merge remote-tracking branch 'origin/master' into codex/custom-pricing-only-departments
# Conflicts:
#	services/nginx/app/tests/Api/OrderItemsApiTest.php
2026-07-06 15:21:31 +02:00
Jeppe BandJeppe Bundgaard 8e46ce1b04 [codex] Allow error reports without screenshots (#299)
* Allow error reports without screenshots

* Stabilize edge gateway shell transcript smoke

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 14:27:47 +02:00
Jeppe Bundgaard 9f797bf6b8 Add opening hours table to API test schema 2026-07-06 14:27:39 +02:00
Jeppe Bundgaard d345db927f Add daily report table to API test schema 2026-07-06 14:16:41 +02:00
Jeppe BandJeppe Bundgaard 11c2a1b72e Block restricted customer order items (#296)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 14:06:29 +02:00
Jeppe Bundgaard eca7a81f9d Add superuser department overview API 2026-07-06 13:59:40 +02:00
Jeppe Bundgaard 62f2c80dda Scope monthly invoice split endpoint 2026-07-06 13:37:31 +02:00
Jeppe BandJeppe Bundgaard 6f3d7e0f7d Add limited backoffice employee contact fields (#294)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 13:14:06 +02:00
Jeppe Bundgaard 430c90cbca Enforce only tankcleaning order products 2026-07-06 12:53:42 +02:00
Jeppe Bundgaard f02dfd8c9c Add customer product fixed price overrides 2026-07-06 12:52:33 +02:00
Jeppe B a8fba73d99 Add limited backoffice role permission details (#291)
Adds grouped safe permission metadata for limited backoffice role presets.
2026-07-06 12:24:54 +02:00
Jeppe B 669759461d Merge pull request #293 from copenhagentruckwash/codex/economic-collected-invoice-transfer-speed
Optimize collected e-conomic invoice transfers
2026-07-06 11:49:47 +02:00
Jeppe Bundgaard db1b9a2c96 Fix e-conomic EAN customer transfer 2026-07-06 11:34:23 +02:00
Jeppe Bundgaard 38814545c4 Optimize collected e-conomic invoice transfers 2026-07-06 11:31:22 +02:00
Jeppe B 94c3654240 Merge pull request #292 from copenhagentruckwash/fix/limited-backoffice-price-save
[codex] Fix limited backoffice price saves on legacy schema
2026-07-06 11:03:44 +02:00
Jeppe Bundgaard 9fa249cc11 Fix limited backoffice price saves on legacy schema 2026-07-06 10:53:18 +02:00
Jeppe Bundgaard 215c8d0fbb Add limited backoffice role permission details 2026-07-06 10:38:51 +02:00
Jeppe Bundgaard 84dec4c0a2 Stabilize custom pricing API fixture 2026-07-06 10:34:57 +02:00
Jeppe Bundgaard d47ea1d659 Add custom-only department pricing enforcement 2026-07-06 10:15:11 +02:00
Jeppe BandJeppe Bundgaard 3f41eebdf6 Default vehicle subscriptions to false (#288)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 09:21:31 +02:00
Jeppe B 64e0b2444b Merge pull request #289 from copenhagentruckwash/fix/limited-backoffice-api-compat
[codex] Fix limited backoffice schema compatibility
2026-07-06 09:20:56 +02:00
Jeppe Bundgaard 18fede78f8 Fix limited backoffice schema compatibility 2026-07-06 09:15:55 +02:00
Jeppe Bundgaard 243d68ab59 Fix edge gateway relay command draining 2026-07-02 12:41:34 +02:00
Jeppe Bundgaard 62c1393f62 Refactor product data handling and improve error management in superuser products layout 2026-07-02 11:48:21 +02:00
Jeppe B f0a8299133 Merge pull request #287 from copenhagentruckwash/fix/api-ci-limited-backoffice
Fix API CI failures
2026-07-02 11:03:12 +02:00
Jeppe Bundgaard e36f6da926 Run Qodana on backend runner pool 2026-07-02 10:57:47 +02:00
Jeppe Bundgaard 4a8c2a9fd9 Avoid npm cache hang in edge CI 2026-07-02 10:50:25 +02:00
Jeppe Bundgaard 248b2e4eca Fix API CI failures 2026-07-02 10:42:13 +02:00
Jeppe Bundgaard 1d1ebd2176 Add limited backoffice functionality with employee management and department pricing 2026-07-01 16:37:32 +02:00
Jeppe Bundgaard 4252f9a42b Stabilize edge gateway API CI 2026-07-01 14:01:50 +02:00
Jeppe Bundgaard 4fdeedab45 Handle invalid edge installer tokens 2026-07-01 13:41:20 +02:00
Jeppe Bundgaard 866a5be126 Refactor subuser permissions and enhance artifact management 2026-07-01 13:18:27 +02:00
Jeppe Bundgaard 11d39af934 Normalize edge broker URL updates 2026-07-01 11:53:01 +02:00
Jeppe Bundgaard f706531534 Stabilize gateway E2E broker config 2026-07-01 11:44:36 +02:00
Jeppe Bundgaard a839eac4c1 Allow HTTP operation completion in gateway E2E 2026-07-01 11:34:39 +02:00
Jeppe Bundgaard 581d28e9ce Stabilize edge gateway CI assertions 2026-07-01 11:25:01 +02:00
Jeppe Bundgaard 2ba39b8174 Fix edge gateway CI broker path 2026-07-01 11:17:01 +02:00
Jeppe Bundgaard 6af55a44c9 Fix API CI broker and transport fixtures 2026-07-01 11:09:38 +02:00
Jeppe Bundgaard 24badc39d7 Keep broker path in API CI public URL 2026-07-01 11:02:26 +02:00
Jeppe Bundgaard f5e0baaab6 Use explicit Docker subnets in API CI 2026-07-01 11:00:13 +02:00
Jeppe Bundgaard 178c84ba60 Use direct broker port for API CI smoke 2026-07-01 10:57:39 +02:00
Jeppe Bundgaard 713d40a876 Prune stale Docker networks in API CI 2026-07-01 10:53:25 +02:00
Jeppe Bundgaard 9db1964038 Fix edge agent CI setup 2026-07-01 10:51:14 +02:00
Jeppe Bundgaard 1ca42055b0 Use docker-capable API runners 2026-07-01 10:42:30 +02:00
Jeppe Bundgaard c04bda7368 Normalize API runner Docker access 2026-07-01 10:38:49 +02:00
Jeppe Bundgaard 1f47843699 Fix edge expected state CI coverage 2026-07-01 10:33:56 +02:00
Jeppe Bundgaard dca738db82 Resolve edge schema DB config from env 2026-07-01 10:28:40 +02:00
Jeppe Bundgaard 57f364ad0f Use backend runners for API tests 2026-07-01 10:21:08 +02:00
Jeppe Bundgaard a826153bb5 Use namespaced DB helper in schema bootstrap 2026-07-01 10:04:28 +02:00
Jeppe Bundgaard 72bd22a707 Load DB helper for edge gateway schema bootstrap 2026-07-01 09:57:34 +02:00
Jeppe Bundgaard cc73d80dbc Use PDO in edge gateway schema bootstrap 2026-07-01 09:46:35 +02:00
Jeppe Bundgaard f0a5b15442 Add edge agent expected relay state API 2026-07-01 09:40:54 +02:00
Jeppe Bundgaard eefe5630f4 Honor staged edge gateway update windows 2026-06-30 17:23:04 +02:00
Jeppe Bundgaard b0ea771e6a Add handling for self-serve machine signals in edge agent 2026-06-30 16:19:43 +02:00
Jeppe Bundgaard eb21405a3d Add outbox replay handling with configurable limits and timeouts 2026-06-30 16:16:08 +02:00
Jeppe Bundgaard 8ea10ef808 Implement relay toggle handling with configurable timers and device generation resolution 2026-06-30 15:53:29 +02:00
Jeppe Bundgaard ac7da807bd Route local lane gate opens through edge bindings 2026-06-30 13:52:12 +02:00
Jeppe Bundgaard 3eafc597c6 Use edge bindings for lane hardware batch relays 2026-06-30 13:28:03 +02:00
Jeppe Bundgaard f7a4126718 Fix router matching for underscored route params 2026-06-30 13:10:06 +02:00
Jeppe Bundgaard c6cf953ede Fix relay batch gateway binding resolution 2026-06-30 12:52:53 +02:00
Jeppe Bundgaard d902202fe9 Fallback relay commands after broker dispatch failures 2026-06-30 12:32:10 +02:00
Jeppe Bundgaard acc80920f9 Preserve LAN worker auth after agent config reload 2026-06-30 11:39:19 +02:00
Jeppe Bundgaard 53246af629 Update EdgeGatewayManagerUrlTest to improve installation phase checks and remove obsolete heartbeat expectations 2026-06-30 11:18:00 +02:00
Jeppe Bundgaard bd87e94472 Normalize line endings in install scripts and enhance service stop commands for robustness 2026-06-30 11:06:33 +02:00
Jeppe Bundgaard 7ccbb68ffa Refactor Dockerfiles to streamline PHP extension checks and remove unnecessary installations 2026-06-30 10:58:08 +02:00
Jeppe Bundgaard 4a7fc7c534 Refactor Dockerfiles to conditionally install curl extension if not already present 2026-06-30 10:35:30 +02:00
Jeppe Bundgaard f4343ae114 Enhance stopping wash functionality with local storage management and restoration logic 2026-06-30 10:16:13 +02:00
Jeppe Bundgaard 0da02dfeb5 Add batch processing for lane hardware commands and status retrieval 2026-06-30 09:30:43 +02:00
Jeppe Bundgaard 5f13242cfa Fix eligibility check by ensuring user is not null in lane access condition 2026-06-29 15:35:26 +02:00
Jeppe Bundgaard b492292642 Add machine wash configuration and update self-serve lane service checks 2026-06-29 15:22:22 +02:00
Jeppe Bundgaard ce8ba88b16 Add machine wash configuration and controls to self-serve module 2026-06-29 15:15:29 +02:00
Jeppe Bundgaard 02b6df5e3b Refactor authentication handling and permission checks in self-serve routes 2026-06-29 14:53:58 +02:00
Jeppe Bundgaard 6cc4f2759d Enhance self-serve functionality by adding lane availability checks and updating response data 2026-06-29 14:25:51 +02:00
Jeppe Bundgaard 3ea92be722 Add endpoint and functionality to test Slack internal department goal progress webhook 2026-06-29 11:32:47 +02:00
Jeppe Bundgaard 42f8ae0c47 Refactor input value handling and improve validation in ObjectsGlobal component 2026-06-29 11:08:50 +02:00
Jeppe Bundgaard 148b575767 Refactor input value handling and improve validation in ObjectsGlobal component 2026-06-29 08:28:00 +02:00
Jeppe B 605efacece Merge pull request #284 from copenhagentruckwash/codex/optimize-scanner-lpr-backend
[codex] optimize scanner LPR backend
2026-06-12 22:08:35 +02:00
159 changed files with 16369 additions and 792 deletions
+8
View File
@@ -11,6 +11,8 @@ services:
edge-broker:
container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker"
ports:
- "127.0.0.1:${EDGE_BROKER_CI_PORT:-14300}:4300"
labels:
- "traefik.http.routers.edge-broker-local-ci.rule=PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local-ci.entrypoints=web"
@@ -80,3 +82,9 @@ services:
volumes:
ci_php_app:
networks:
default:
ipam:
config:
- subnet: "${CI_DOCKER_SUBNET:-10.240.0.0/24}"
+1 -1
View File
@@ -10,7 +10,7 @@ on:
jobs:
qodana:
# CI runs on the repository's self-hosted runner pool.
runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
permissions:
contents: read
pull-requests: read
+97 -40
View File
@@ -7,7 +7,7 @@ on:
jobs:
php:
name: PHP ${{ matrix.suite }} (required)
runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
strategy:
fail-fast: false
matrix:
@@ -19,6 +19,20 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Ensure Docker access
run: |
set -euo pipefail
if docker ps >/dev/null 2>&1; then
exit 0
fi
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
if command -v sudo >/dev/null 2>&1; then
sudo -n chmod 666 /var/run/docker.sock
else
chmod 666 /var/run/docker.sock
fi
docker ps >/dev/null
- name: Setup Node.js
if: ${{ matrix.suite == 'unit' }}
uses: actions/setup-node@v4
@@ -44,7 +58,7 @@ jobs:
edge-agent:
name: Edge Agent (required)
runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, backend]
steps:
- name: Checkout
@@ -54,8 +68,6 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: services/edge-agent/package-lock.json
- name: Install native build tools
run: |
@@ -91,12 +103,26 @@ jobs:
edge-broker:
name: Edge Broker (required)
runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Ensure Docker access
run: |
set -euo pipefail
if docker ps >/dev/null 2>&1; then
exit 0
fi
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
if command -v sudo >/dev/null 2>&1; then
sudo -n chmod 666 /var/run/docker.sock
else
chmod 666 /var/run/docker.sock
fi
docker ps >/dev/null
- name: Materialize CI compose env files
run: |
set -euo pipefail
@@ -112,8 +138,6 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: services/edge-broker/package-lock.json
- name: Install dependencies
working-directory: services/edge-broker
@@ -125,26 +149,83 @@ jobs:
edge-gateway-backend:
name: Edge Gateway Backend (required)
runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
env:
COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
COMPOSE_PROFILES: dev
TRAEFIK_WEB_PORT: "18080"
TRAEFIK_WEBSECURE_PORT: "18443"
TRAEFIK_WEBSECURE_STAGING_PORT: "18433"
TRAEFIK_METRICS_PORT: "19100"
EDGE_BROKER_CI_PORT: "14300"
EDGE_GATEWAY_E2E_BASE_URL: "http://localhost:18080/api"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Ensure Docker access
run: |
set -euo pipefail
if docker ps >/dev/null 2>&1; then
exit 0
fi
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
if command -v sudo >/dev/null 2>&1; then
sudo -n chmod 666 /var/run/docker.sock
else
chmod 666 /var/run/docker.sock
fi
docker ps >/dev/null
- name: Allocate CI ports
run: |
set -euo pipefail
find_free_port() {
start="$1"
end="$2"
port="$start"
while [ "$port" -le "$end" ]; do
if ! ss -H -ltn "sport = :$port" 2>/dev/null | grep -q .; then
echo "$port"
return 0
fi
port=$((port + 1))
done
echo "No free port in range ${start}-${end}." >&2
exit 1
}
base=$((20000 + (GITHUB_RUN_ID % 20000)))
web_port="$(find_free_port "$base" "$((base + 2000))")"
websecure_port="$(find_free_port "$((web_port + 1))" "$((web_port + 2000))")"
staging_port="$(find_free_port "$((websecure_port + 1))" "$((websecure_port + 2000))")"
metrics_port="$(find_free_port "$((staging_port + 1))" "$((staging_port + 2000))")"
broker_port="$(find_free_port "$((metrics_port + 1))" "$((metrics_port + 2000))")"
checksum="$(printf '%s' "$COMPOSE_PROJECT_NAME" | cksum | awk '{print $1}')"
subnet_second=$((64 + ((checksum / 256) % 64)))
subnet_third=$((checksum % 256))
ci_docker_subnet="10.${subnet_second}.${subnet_third}.0/24"
{
echo "TRAEFIK_WEB_PORT=${web_port}"
echo "TRAEFIK_WEBSECURE_PORT=${websecure_port}"
echo "TRAEFIK_WEBSECURE_STAGING_PORT=${staging_port}"
echo "TRAEFIK_METRICS_PORT=${metrics_port}"
echo "EDGE_BROKER_CI_PORT=${broker_port}"
echo "CI_DOCKER_SUBNET=${ci_docker_subnet}"
echo "EDGE_GATEWAY_E2E_BASE_URL=http://localhost:${web_port}/api"
echo "EDGE_GATEWAY_E2E_COMPOSE_PROJECT=${COMPOSE_PROJECT_NAME}"
} >> "$GITHUB_ENV"
- name: Materialize CI compose env files
run: |
set -euo pipefail
cp .github/ci.env .env
cp .github/ci.env.staging .env.staging
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300\n' >> .env
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300/edge-broker\n' >> .env
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -152,7 +233,7 @@ jobs:
node-version: 22
- name: Boot local stack
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
run: sh scripts/ci-docker-compose-up.sh traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
- name: Sync PHP app checkout
run: >
@@ -161,7 +242,7 @@ jobs:
--exclude='./.phpunit.cache'
--exclude='./build/logs'
-C services/nginx/app -cf - .
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar --no-same-owner -C /var/www/html -xf -
- name: Resolve dependencies
run: |
@@ -246,34 +327,10 @@ jobs:
vendor/bin/pest tests/Integration/EdgeGateway --colors=always"
- name: Run edge gateway E2E smoke
run: |
set -euo pipefail
compose_project="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}"
runner="edge-e2e-runner-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
docker rm -f "$runner" >/dev/null 2>&1 || true
trap 'docker rm -f "$runner" >/dev/null 2>&1 || true' EXIT
docker create \
--name "$runner" \
--network "${compose_project}_default" \
-e COMPOSE_FILE="$COMPOSE_FILE" \
-e COMPOSE_PROJECT_NAME="$compose_project" \
-e TRAEFIK_WEB_PORT="${TRAEFIK_WEB_PORT:-18080}" \
-e TRAEFIK_WEBSECURE_PORT="${TRAEFIK_WEBSECURE_PORT:-18443}" \
-e TRAEFIK_WEBSECURE_STAGING_PORT="${TRAEFIK_WEBSECURE_STAGING_PORT:-18433}" \
-e TRAEFIK_METRICS_PORT="${TRAEFIK_METRICS_PORT:-19100}" \
-e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \
-e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \
-e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \
-e EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP="true" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace \
node:22-alpine \
sh -lc "apk add --no-cache docker-cli docker-cli-compose >/dev/null && node scripts/edge-gateway-e2e.mjs"
docker cp . "$runner:/workspace"
docker start "$runner" >/dev/null
docker logs -f "$runner"
exit_code="$(docker wait "$runner")"
exit "$exit_code"
env:
EDGE_GATEWAY_E2E_COPY_CONFIG: "true"
EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP: "true"
run: node scripts/edge-gateway-e2e.mjs
- name: Tear down local stack
if: always()
@@ -281,7 +338,7 @@ jobs:
release-manager-gate:
name: Release Manager gate
runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, backend]
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
+4 -1
View File
@@ -13,4 +13,7 @@
.env.old
/.tmp/
/.env.staging
/services/nginx/app/storage/replication-bootstrap.json
/services/nginx/app/storage/replication-bootstrap.json
/.env_old_2
/.openclaw/
/services/nginx/app/build/phpstan/
+256 -6
View File
@@ -40,6 +40,8 @@ tags:
description: Account security and passkey management endpoints
- name: Users
description: User management and customer operations
- name: Limited Backoffice
description: Limited backoffice employee and department management
- name: Search
description: System-wide search endpoints
- name: Orders
@@ -2762,6 +2764,44 @@ paths:
properties:
token: {type: string}
/limited-backoffice/employees/{employeeId}/login-link:
post:
tags:
- Limited Backoffice
summary: Create a managed employee QR login link
description: Create a reusable auth-token login link for an active employee managed through the limited backoffice.
operationId: createLimitedBackofficeEmployeeLoginLink
parameters:
- name: employeeId
in: path
required: true
schema:
type: integer
minimum: 1
responses:
'200':
description: Login link created successfully
content:
application/json:
schema:
type: object
properties:
employee_id:
type: integer
login_path:
type: string
example: /login/qr?token=abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
# User Endpoints
/users:
get:
@@ -7462,7 +7502,7 @@ paths:
application/json:
schema:
type: object
required: [reg, type, wash_subscription]
required: [reg, type]
properties:
reg:
type: string
@@ -7474,6 +7514,8 @@ paths:
description: Product ID representing the vehicle wash type
wash_subscription:
type: boolean
default: false
description: Defaults to false when omitted.
reference:
type: string
maxLength: 255
@@ -11665,6 +11707,70 @@ paths:
schema:
$ref: '#/components/schemas/ModuleConfigUpdateResponse'
/slack/config/test:
post:
tags: [Config]
summary: Test Slack customer registration webhook
operationId: testSlackCustomerRegistrationWebhook
responses:
'200':
description: Slack customer registration webhook test completed successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SlackConfigTestResponse'
'400':
description: Slack customer registration webhook URL is not configured
'502':
description: Slack customer registration webhook test failed
/slack/config/internal-department-goal-progress:
get:
tags: [Config]
summary: Get Slack internal department goal progress config
operationId: getSlackInternalDepartmentGoalProgressConfig
responses:
'200':
description: Slack internal department goal progress configuration retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfigResponse'
post:
tags: [Config]
summary: Update Slack internal department goal progress config
operationId: updateSlackInternalDepartmentGoalProgressConfig
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfigUpdate'
responses:
'200':
description: Slack internal department goal progress configuration updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfigResponse'
/slack/config/internal-department-goal-progress/test:
post:
tags: [Config]
summary: Test Slack internal department goal progress webhook
operationId: testSlackInternalDepartmentGoalProgressWebhook
responses:
'200':
description: Slack internal department goal progress webhook test completed successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SlackConfigTestResponse'
'400':
description: Slack internal department goal progress webhook URL is not configured
'502':
description: Slack internal department goal progress webhook test failed
/backups/config:
get:
tags: [Config]
@@ -12339,6 +12445,29 @@ paths:
application/json:
schema: {}
/roles/limited-backoffice-permission-templates:
get:
tags:
- Roles
summary: List limited backoffice permission templates
operationId: listLimitedBackofficeRolePermissionTemplates
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
type: object
properties:
key: {type: string}
label: {type: string}
description: {type: string}
permissions:
type: array
items: {type: string}
/roles/permissions:
post:
tags:
@@ -12651,6 +12780,33 @@ paths:
schema:
$ref: '#/components/schemas/DepartmentDailyReportOverviewResponse'
/departments/daily-reports/product-targets:
put:
tags:
- Departments
summary: Set daily report product target
description: Requires set_department_daily_report_product_targets and department_access_:department_id. Send a null target_percentage to clear the target.
operationId: setDailyReportProductTarget
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportProductTargetRequest'
responses:
'200':
description: Daily report product target updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportProductTargetResponse'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/departments/daily-reports/get:
get:
tags:
@@ -13322,7 +13478,6 @@ components:
- expected
- actual
- data_collection_accepted
- screenshot
properties:
before_error:
type: string
@@ -13338,10 +13493,11 @@ components:
description: What actually happened
data_collection_accepted:
type: boolean
description: Required acceptance of collecting screenshot and diagnostic error data
description: Required acceptance of collecting diagnostic error data and a screenshot when one can be attached
screenshot:
type: string
description: PNG, JPEG, or WebP data URI of the current app viewport
nullable: true
description: Optional PNG, JPEG, or WebP data URI of the current app viewport. Reports are accepted without an attachment when capture or upload fails.
route_path:
type: string
nullable: true
@@ -13452,6 +13608,7 @@ components:
nullable: true
screenshot:
type: object
nullable: true
additionalProperties: true
answers:
type: object
@@ -15183,12 +15340,59 @@ components:
type: object
properties:
module: { type: string, enum: [Slack] }
variable: { type: string, enum: [customer_registration_webhook_url] }
variable: { type: string, enum: [customer_registration_webhook_url, internal_department_goal_progress_webhook_url, internal_department_ids] }
type: { type: string, enum: [string] }
value:
oneOf:
- type: string
example: https://hooks.slack.com/services/...
- type: string
example: '[1,2,3]'
required: [module, variable, type, value]
SlackConfigTestResult:
type: object
properties:
configured: { type: boolean }
sent: { type: boolean }
message: { type: string }
required: [configured, sent, message]
SlackInternalDepartmentGoalProgressDepartment:
type: object
properties:
id: { type: integer }
name: { type: string }
order_priority: { type: integer }
required: [id, name, order_priority]
SlackInternalDepartmentGoalProgressConfig:
type: object
properties:
internal_department_goal_progress_webhook_url:
type: string
example: https://hooks.slack.com/services/...
required: [module, variable, type, value]
internal_department_ids:
type: array
items: { type: integer }
example: [1, 2, 3]
departments:
type: array
items:
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressDepartment'
required: [internal_department_goal_progress_webhook_url, internal_department_ids, departments]
SlackInternalDepartmentGoalProgressConfigUpdate:
type: object
properties:
internal_department_goal_progress_webhook_url:
type: string
example: https://hooks.slack.com/services/...
internal_department_ids:
type: array
items: { type: integer }
example: [1, 2, 3]
required: [internal_department_goal_progress_webhook_url, internal_department_ids]
BackupsConfigEntry:
type: object
@@ -15466,6 +15670,22 @@ components:
data: { type: array, items: { $ref: '#/components/schemas/SlackConfigEntry' } }
required: [data]
SlackConfigTestResponse:
allOf:
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
- type: object
properties:
data: { $ref: '#/components/schemas/SlackConfigTestResult' }
required: [data]
SlackInternalDepartmentGoalProgressConfigResponse:
allOf:
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
- type: object
properties:
data: { $ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfig' }
required: [data]
BackupsConfigListResponse:
allOf:
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
@@ -21396,6 +21616,36 @@ components:
state: { type: string }
value: { type: integer }
out_of: { type: integer }
target_percentage: { type: number, format: float, nullable: true }
target_department_id: { type: integer, nullable: true }
DepartmentDailyReportProductTargetRequest:
type: object
required:
- department_id
- product_id
- target_percentage
properties:
department_id: { type: integer }
product_id: { type: integer }
target_percentage:
type: number
format: float
nullable: true
DepartmentDailyReportProductTarget:
type: object
properties:
department_id: { type: integer }
product_id: { type: integer }
target_percentage: { type: number, format: float, nullable: true }
DepartmentDailyReportProductTargetResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
$ref: '#/components/schemas/DepartmentDailyReportProductTarget'
DepartmentDailyReportOverviewPayload:
type: object
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env sh
set -eu
if [ "$#" -eq 0 ]; then
echo "Usage: $0 <service> [service ...]" >&2
exit 2
fi
compose_files="${CI_DOCKER_COMPOSE_FILES:--f docker-compose.yml -f .github/docker-compose.ci.yml}"
lock_file="${CI_DOCKER_LOCK_FILE:-/tmp/pleno-api-ci-docker-compose-up.lock}"
max_attempts="${CI_DOCKER_UP_RETRIES:-${PHP_CI_DOCKER_RETRIES:-3}}"
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
compose_up() {
attempt=1
while :; do
docker network prune -f >/dev/null 2>&1 || true
if docker compose $compose_files up -d "$@"; then
return 0
fi
status="$?"
docker compose $compose_files down -v --remove-orphans >/dev/null 2>&1 || true
if [ "$attempt" -ge "$max_attempts" ]; then
return "$status"
fi
sleep_seconds=$((attempt * 5))
echo "Docker compose up failed with status $status; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/$max_attempts)." >&2
sleep "$sleep_seconds"
attempt=$((attempt + 1))
done
}
if command -v flock >/dev/null 2>&1; then
(
flock 9
compose_up "$@"
) 9>"$lock_file"
else
echo "flock is not available; running Docker compose startup without a host lock." >&2
compose_up "$@"
fi
+453
View File
@@ -0,0 +1,453 @@
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_AGENT_PATH = path.join(
repoRoot,
"services/nginx/app/resources/edge-gateway-agent/agent.php"
);
const DEFAULT_PHP_IMAGE = "php:8.2-cli-bookworm";
const DEFAULT_TIMEOUT_MS = 12000;
function parseArgs(argv = process.argv.slice(2)) {
const options = {
agentPath: DEFAULT_AGENT_PATH,
phpImage: DEFAULT_PHP_IMAGE,
timeoutMs: DEFAULT_TIMEOUT_MS,
keepTemp: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const next = argv[index + 1];
switch (arg) {
case "--agent-path":
options.agentPath = path.resolve(String(next || "").trim());
index += 1;
break;
case "--php-image":
options.phpImage = String(next || "").trim() || DEFAULT_PHP_IMAGE;
index += 1;
break;
case "--timeout-ms":
options.timeoutMs = Number.parseInt(String(next || ""), 10) || DEFAULT_TIMEOUT_MS;
index += 1;
break;
case "--keep-temp":
options.keepTemp = true;
break;
case "--help":
case "-h":
options.help = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function printUsage() {
process.stdout.write(`Usage:
node scripts/edge-agent-command-drain-proof.mjs [options]
Verifies that a broker-connected PHP compose edge agent still drains API-queued
SET_RELAY_STATE jobs to the LAN worker /relay/switch endpoint.
Options:
--agent-path <path> PHP agent artifact to execute.
Default: ${DEFAULT_AGENT_PATH}
--php-image <image> Docker PHP image with curl, sqlite3, and pdo_sqlite.
Default: ${DEFAULT_PHP_IMAGE}
--timeout-ms <ms> Proof timeout. Default: ${DEFAULT_TIMEOUT_MS}
--keep-temp Keep the temporary config/runtime directory.
--help Show this help text.
`);
}
function readJson(request) {
return new Promise((resolve) => {
let raw = "";
request.setEncoding("utf8");
request.on("data", (chunk) => {
raw += chunk;
});
request.on("end", () => {
if (raw.trim() === "") {
resolve({});
return;
}
try {
resolve(JSON.parse(raw));
} catch {
resolve({ __invalid: raw });
}
});
});
}
function sendJson(response, status, payload) {
const body = JSON.stringify(payload);
response.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(body),
});
response.end(body);
}
function listen(server) {
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
});
}
function closeServer(server) {
return new Promise((resolve) => {
server.close(() => resolve());
});
}
function websocketAcceptKey(key) {
return crypto
.createHash("sha1")
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
.digest("base64");
}
function createBrokerServer(state) {
const sockets = new Set();
const server = net.createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
let buffer = "";
socket.on("data", (chunk) => {
buffer += chunk.toString("binary");
if (state.brokerHandshakeSeen || !buffer.includes("\r\n\r\n")) {
return;
}
const requestText = Buffer.from(buffer, "binary").toString("utf8");
const key = requestText.match(/Sec-WebSocket-Key:\s*(.+)\r\n/i)?.[1]?.trim();
const requestLine = requestText.split("\r\n")[0] || "";
if (!requestLine.includes("/ws/agent?")) {
state.failure = new Error(`unexpected broker path: ${requestLine}`);
}
if (!key) {
state.failure = new Error("broker handshake missing Sec-WebSocket-Key");
return;
}
socket.write([
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${websocketAcceptKey(key)}`,
"",
"",
].join("\r\n"));
state.brokerHandshakeSeen = true;
buffer = "";
});
});
return { server, sockets };
}
function createWorkerServer(state) {
return http.createServer(async (request, response) => {
const url = new URL(request.url, "http://127.0.0.1");
const body = await readJson(request);
state.requests.push({ service: "worker", method: request.method, path: url.pathname, body });
if (request.method === "GET" && url.pathname === "/health") {
sendJson(response, 200, { status: "healthy", timestamp: new Date().toISOString() });
return;
}
if (request.method === "POST" && url.pathname === "/relay/switch") {
state.relaySwitchSeen = true;
if (body.local_ip !== "10.123.0.31" || body.channel !== 0 || body.on !== true) {
state.failure = new Error(`unexpected relay switch payload: ${JSON.stringify(body)}`);
}
sendJson(response, 200, {
online: true,
on: true,
output: true,
raw: { source: "fake-worker" },
});
return;
}
sendJson(response, 404, { message: "not found" });
});
}
function createApiServer(state, brokerPort, workerPort) {
return http.createServer(async (request, response) => {
const url = new URL(request.url, "http://127.0.0.1");
const body = await readJson(request);
state.requests.push({ service: "api", method: request.method, path: url.pathname, body });
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/heartbeat") {
sendJson(response, 200, { data: { ok: true, broker_url: `ws://127.0.0.1:${brokerPort}` } });
return;
}
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/selfserve/machine-signal-bindings") {
sendJson(response, 200, { data: { monitors: [] } });
return;
}
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/poll") {
state.commandPollSeen = true;
if (body.wait_seconds !== 0) {
state.failure = new Error(
`broker-connected command poll should be non-blocking, got wait_seconds=${body.wait_seconds}`
);
}
if (!state.commandDelivered) {
state.commandDelivered = true;
sendJson(response, 200, {
data: {
id: 77,
command_type: "SET_RELAY_STATE",
payload: {
localIp: "10.123.0.31",
channel: 0,
on: true,
relayId: "relay-proof",
},
},
});
return;
}
sendJson(response, 200, { data: null });
return;
}
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/77/result") {
state.resultSeen = true;
if (body.ok !== true || body.result?.on !== true || body.result?.raw?.source !== "fake-worker") {
state.failure = new Error(`unexpected command result: ${JSON.stringify(body)}`);
}
sendJson(response, 200, { data: { acknowledged: true } });
return;
}
sendJson(response, 404, { message: "not found", path: url.pathname, workerPort });
});
}
function writeConfig(tempDir, apiPort, brokerPort, workerPort) {
const containerProofDir = "/proof";
const runtimeDir = `${containerProofDir}/runtime`;
const config = {
apiUrl: `http://127.0.0.1:${apiPort}`,
brokerUrl: `ws://127.0.0.1:${brokerPort}`,
gatewayId: 42,
agentToken: "agent-token",
installDir: containerProofDir,
runtimeDir,
stateDatabasePath: `${runtimeDir}/gateway-state.sqlite`,
workerBaseUrl: `http://127.0.0.1:${workerPort}`,
heartbeatIntervalSeconds: 60,
operationPollTimeoutSeconds: 20,
};
const configPath = path.join(tempDir, "config.json");
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return { configPath, containerConfigPath: `${containerProofDir}/config.json` };
}
function spawnAgent({ agentPath, phpImage, tempDir, containerConfigPath }) {
return spawn("docker", [
"run",
"--rm",
"--network",
"host",
"-v",
`${agentPath}:/agent.php:ro`,
"-v",
`${tempDir}:/proof`,
phpImage,
"php",
"/agent.php",
"--config",
containerConfigPath,
], { stdio: ["ignore", "pipe", "pipe"] });
}
async function stopChild(child) {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
child.kill("SIGTERM");
const hardKill = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}, 1500);
await Promise.race([
new Promise((resolve) => child.once("exit", resolve)),
new Promise((resolve) => setTimeout(resolve, 2200)),
]);
clearTimeout(hardKill);
}
function evidenceFromState(state, childExited) {
return {
brokerHandshakeSeen: state.brokerHandshakeSeen,
commandPollSeen: state.commandPollSeen,
relaySwitchSeen: state.relaySwitchSeen,
resultSeen: state.resultSeen,
agentStayedRunningUntilProofComplete: !childExited,
};
}
export async function runProof(options) {
if (process.platform !== "linux") {
throw new Error("This proof uses Docker --network host and currently expects Linux.");
}
if (!fs.existsSync(options.agentPath)) {
throw new Error(`Agent artifact not found: ${options.agentPath}`);
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "edge-agent-command-drain-proof-"));
fs.mkdirSync(path.join(tempDir, "runtime"), { recursive: true });
const state = {
brokerHandshakeSeen: false,
commandPollSeen: false,
relaySwitchSeen: false,
resultSeen: false,
commandDelivered: false,
failure: null,
requests: [],
};
const broker = createBrokerServer(state);
const workerServer = createWorkerServer(state);
let apiServer = null;
let child = null;
let stdout = "";
let stderr = "";
let childExited = false;
try {
const brokerPort = await listen(broker.server);
const workerPort = await listen(workerServer);
apiServer = createApiServer(state, brokerPort, workerPort);
const apiPort = await listen(apiServer);
const { containerConfigPath } = writeConfig(tempDir, apiPort, brokerPort, workerPort);
child = spawnAgent({ ...options, tempDir, containerConfigPath });
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.once("exit", () => {
childExited = true;
});
const deadline = Date.now() + options.timeoutMs;
while (Date.now() < deadline && !state.failure && !childExited) {
if (state.brokerHandshakeSeen && state.commandPollSeen && state.relaySwitchSeen && state.resultSeen) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
const evidence = evidenceFromState(state, childExited);
if (
state.failure ||
!state.brokerHandshakeSeen ||
!state.commandPollSeen ||
!state.relaySwitchSeen ||
!state.resultSeen
) {
const error = state.failure || new Error("missing proof evidence");
error.evidence = evidence;
error.requests = state.requests;
error.stdout = stdout.slice(-3000);
error.stderr = stderr.slice(-3000);
throw error;
}
return {
evidence,
agentPath: options.agentPath,
phpImage: options.phpImage,
tempDir,
requestCount: state.requests.length,
};
} finally {
if (child) {
await stopChild(child);
}
for (const socket of broker.sockets) {
socket.destroy();
}
await Promise.allSettled([
closeServer(broker.server),
closeServer(workerServer),
apiServer ? closeServer(apiServer) : Promise.resolve(),
]);
if (!options.keepTemp) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
}
async function main() {
const options = parseArgs();
if (options.help) {
printUsage();
return;
}
const result = await runProof(options);
process.stdout.write("PASS broker-connected API command poll triggered local relay switch and posted result\n");
process.stdout.write(`${JSON.stringify(result.evidence)}\n`);
process.stdout.write(`Agent: ${result.agentPath}\n`);
process.stdout.write(`PHP image: ${result.phpImage}\n`);
if (options.keepTemp) {
process.stdout.write(`Temp dir: ${result.tempDir}\n`);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
process.stderr.write(`FAIL ${error.message}\n`);
if (error.evidence) {
process.stderr.write(`Evidence: ${JSON.stringify(error.evidence)}\n`);
}
if (error.requests) {
process.stderr.write(`Requests: ${JSON.stringify(error.requests, null, 2)}\n`);
}
if (error.stdout) {
process.stderr.write(`stdout: ${error.stdout}\n`);
}
if (error.stderr) {
process.stderr.write(`stderr: ${error.stderr}\n`);
}
process.exit(1);
});
}
+619
View File
@@ -0,0 +1,619 @@
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_AGENT_PATH = path.join(
repoRoot,
"services/nginx/app/resources/edge-gateway-agent/agent.php"
);
const DEFAULT_WORKER_PATH = path.join(
repoRoot,
"services/nginx/app/resources/edge-gateway-agent/lan-worker.php"
);
const DEFAULT_PHP_IMAGE = "php:8.2-cli-bookworm";
const DEFAULT_TIMEOUT_MS = 15000;
const AGENT_TOKEN = "agent-token";
function parseArgs(argv = process.argv.slice(2)) {
const options = {
agentPath: DEFAULT_AGENT_PATH,
workerPath: DEFAULT_WORKER_PATH,
phpImage: DEFAULT_PHP_IMAGE,
timeoutMs: DEFAULT_TIMEOUT_MS,
keepTemp: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const next = argv[index + 1];
switch (arg) {
case "--agent-path":
options.agentPath = path.resolve(String(next || "").trim());
index += 1;
break;
case "--worker-path":
options.workerPath = path.resolve(String(next || "").trim());
index += 1;
break;
case "--php-image":
options.phpImage = String(next || "").trim() || DEFAULT_PHP_IMAGE;
index += 1;
break;
case "--timeout-ms":
options.timeoutMs = Number.parseInt(String(next || ""), 10) || DEFAULT_TIMEOUT_MS;
index += 1;
break;
case "--keep-temp":
options.keepTemp = true;
break;
case "--help":
case "-h":
options.help = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function printUsage() {
process.stdout.write(`Usage:
node scripts/edge-agent-to-shelly-proof.mjs [options]
Runs the PHP edge agent and real LAN worker against fake broker, API, and
Shelly RPC endpoints. Verifies that a broker-connected SET_RELAY_STATE command
drains from the API, reaches the worker, triggers a Shelly-style Switch.Set
call, reads Switch.GetStatus, and posts the command result.
Options:
--agent-path <path> PHP agent artifact to execute.
Default: ${DEFAULT_AGENT_PATH}
--worker-path <path> PHP LAN worker artifact to execute.
Default: ${DEFAULT_WORKER_PATH}
--php-image <image> Docker PHP image with curl, sqlite3, and pdo_sqlite.
Default: ${DEFAULT_PHP_IMAGE}
--timeout-ms <ms> Proof timeout. Default: ${DEFAULT_TIMEOUT_MS}
--keep-temp Keep the temporary config/runtime directory.
--help Show this help text.
`);
}
function readJson(request) {
return new Promise((resolve) => {
let raw = "";
request.setEncoding("utf8");
request.on("data", (chunk) => {
raw += chunk;
});
request.on("end", () => {
if (raw.trim() === "") {
resolve({});
return;
}
try {
resolve(JSON.parse(raw));
} catch {
resolve({ __invalid: raw });
}
});
});
}
function sendJson(response, status, payload) {
const body = JSON.stringify(payload);
response.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(body),
});
response.end(body);
}
function requestJson({ method = "GET", port, path: requestPath, body = null, headers = {} }) {
return new Promise((resolve, reject) => {
const payload = body === null ? null : JSON.stringify(body);
const request = http.request({
hostname: "127.0.0.1",
port,
path: requestPath,
method,
headers: {
accept: "application/json",
...(payload === null ? {} : {
"content-type": "application/json",
"content-length": Buffer.byteLength(payload),
}),
...headers,
},
timeout: 1000,
}, (response) => {
let raw = "";
response.setEncoding("utf8");
response.on("data", (chunk) => {
raw += chunk;
});
response.on("end", () => {
let decoded = {};
try {
decoded = raw.trim() === "" ? {} : JSON.parse(raw);
} catch {
decoded = { __invalid: raw };
}
resolve({ status: response.statusCode || 0, body: decoded });
});
});
request.on("error", reject);
request.on("timeout", () => {
request.destroy(new Error("request timed out"));
});
if (payload !== null) {
request.write(payload);
}
request.end();
});
}
function listen(server) {
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
});
}
function closeServer(server) {
return new Promise((resolve) => {
server.close(() => resolve());
});
}
async function reservePort() {
const server = net.createServer();
const port = await new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
});
await closeServer(server);
return port;
}
function websocketAcceptKey(key) {
return crypto
.createHash("sha1")
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
.digest("base64");
}
function createBrokerServer(state) {
const sockets = new Set();
const server = net.createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
let buffer = "";
socket.on("data", (chunk) => {
buffer += chunk.toString("binary");
if (state.brokerHandshakeSeen || !buffer.includes("\r\n\r\n")) {
return;
}
const requestText = Buffer.from(buffer, "binary").toString("utf8");
const key = requestText.match(/Sec-WebSocket-Key:\s*(.+)\r\n/i)?.[1]?.trim();
const requestLine = requestText.split("\r\n")[0] || "";
if (!requestLine.includes("/ws/agent?")) {
state.failure = new Error(`unexpected broker path: ${requestLine}`);
}
if (!key) {
state.failure = new Error("broker handshake missing Sec-WebSocket-Key");
return;
}
socket.write([
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${websocketAcceptKey(key)}`,
"",
"",
].join("\r\n"));
state.brokerHandshakeSeen = true;
buffer = "";
});
});
return { server, sockets };
}
function createShellyServer(state) {
return http.createServer((request, response) => {
const url = new URL(request.url, "http://127.0.0.1");
state.requests.push({
service: "shelly",
method: request.method,
path: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
});
if (request.method === "GET" && url.pathname === "/rpc/Switch.Set") {
state.shellySwitchSetSeen = true;
if (url.searchParams.get("id") !== "0" || url.searchParams.get("on") !== "true") {
state.failure = new Error(`unexpected Shelly Switch.Set query: ${url.search}`);
}
sendJson(response, 200, { was_on: false, output: true });
return;
}
if (request.method === "GET" && url.pathname === "/rpc/Switch.GetStatus") {
state.shellyStatusSeen = true;
if (url.searchParams.get("id") !== "0") {
state.failure = new Error(`unexpected Shelly Switch.GetStatus query: ${url.search}`);
}
sendJson(response, 200, { id: 0, output: true, source: "fake-shelly-rpc" });
return;
}
if (url.pathname.startsWith("/relay/")) {
state.failure = new Error(`legacy Shelly endpoint should not be used for generation 2 proof: ${url.pathname}`);
}
sendJson(response, 404, { message: "not found" });
});
}
function createApiServer(state, brokerPort, shellyAddress) {
return http.createServer(async (request, response) => {
const url = new URL(request.url, "http://127.0.0.1");
const body = await readJson(request);
state.requests.push({ service: "api", method: request.method, path: url.pathname, body });
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/heartbeat") {
sendJson(response, 200, { data: { ok: true, broker_url: `ws://127.0.0.1:${brokerPort}` } });
return;
}
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/selfserve/machine-signal-bindings") {
sendJson(response, 200, { data: { monitors: [] } });
return;
}
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/poll") {
state.commandPollSeen = true;
if (body.wait_seconds !== 0) {
state.failure = new Error(
`broker-connected command poll should be non-blocking, got wait_seconds=${body.wait_seconds}`
);
}
if (!state.commandDelivered) {
state.commandDelivered = true;
sendJson(response, 200, {
data: {
id: 77,
command_type: "SET_RELAY_STATE",
payload: {
localIp: shellyAddress,
local_ip: shellyAddress,
channel: 0,
on: true,
relayId: "relay-proof",
relay_id: "relay-proof",
deviceGeneration: 2,
device_generation: 2,
},
},
});
return;
}
sendJson(response, 200, { data: null });
return;
}
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/77/result") {
state.resultSeen = true;
if (
body.ok !== true ||
body.result?.on !== true ||
body.result?.output !== true ||
body.result?.raw?.source !== "fake-shelly-rpc"
) {
state.failure = new Error(`unexpected command result: ${JSON.stringify(body)}`);
}
sendJson(response, 200, { data: { acknowledged: true } });
return;
}
sendJson(response, 404, { message: "not found", path: url.pathname });
});
}
function writeConfig(tempDir, apiPort, brokerPort, workerPort) {
const containerProofDir = "/proof";
const runtimeDir = `${containerProofDir}/runtime`;
const config = {
apiUrl: `http://127.0.0.1:${apiPort}`,
brokerUrl: `ws://127.0.0.1:${brokerPort}`,
gatewayId: 42,
agentToken: AGENT_TOKEN,
installDir: containerProofDir,
runtimeDir,
stateDatabasePath: `${runtimeDir}/gateway-state.sqlite`,
workerBaseUrl: `http://127.0.0.1:${workerPort}`,
heartbeatIntervalSeconds: 60,
operationPollTimeoutSeconds: 20,
};
const configPath = path.join(tempDir, "config.json");
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return { containerConfigPath: `${containerProofDir}/config.json` };
}
function spawnWorker({ workerPath, phpImage, workerPort }) {
return spawn("docker", [
"run",
"--rm",
"--network",
"host",
"-e",
`TRUCKWASH_WORKER_TOKEN=${AGENT_TOKEN}`,
"-v",
`${workerPath}:/lan-worker.php:ro`,
phpImage,
"php",
"-S",
`127.0.0.1:${workerPort}`,
"/lan-worker.php",
], { stdio: ["ignore", "pipe", "pipe"] });
}
function spawnAgent({ agentPath, phpImage, tempDir, containerConfigPath }) {
return spawn("docker", [
"run",
"--rm",
"--network",
"host",
"-v",
`${agentPath}:/agent.php:ro`,
"-v",
`${tempDir}:/proof`,
phpImage,
"php",
"/agent.php",
"--config",
containerConfigPath,
], { stdio: ["ignore", "pipe", "pipe"] });
}
async function waitForWorker(workerPort, child, timeoutMs) {
const deadline = Date.now() + timeoutMs;
let lastError = null;
while (Date.now() < deadline) {
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(`LAN worker exited before becoming healthy: ${child.exitCode ?? child.signalCode}`);
}
try {
const response = await requestJson({ port: workerPort, path: "/health" });
if (response.status === 200 && response.body?.service === "lan-worker") {
return;
}
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw lastError || new Error("LAN worker did not become healthy");
}
async function stopChild(child) {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
child.kill("SIGTERM");
const hardKill = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}, 1500);
await Promise.race([
new Promise((resolve) => child.once("exit", resolve)),
new Promise((resolve) => setTimeout(resolve, 2200)),
]);
clearTimeout(hardKill);
}
function evidenceFromState(state, agentExited, workerExited) {
return {
brokerHandshakeSeen: state.brokerHandshakeSeen,
commandPollSeen: state.commandPollSeen,
shellySwitchSetSeen: state.shellySwitchSetSeen,
shellyStatusSeen: state.shellyStatusSeen,
resultSeen: state.resultSeen,
agentStayedRunningUntilProofComplete: !agentExited,
workerStayedRunningUntilProofComplete: !workerExited,
};
}
export async function runProof(options) {
if (process.platform !== "linux") {
throw new Error("This proof uses Docker --network host and currently expects Linux.");
}
if (!fs.existsSync(options.agentPath)) {
throw new Error(`Agent artifact not found: ${options.agentPath}`);
}
if (!fs.existsSync(options.workerPath)) {
throw new Error(`LAN worker artifact not found: ${options.workerPath}`);
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "edge-agent-to-shelly-proof-"));
fs.mkdirSync(path.join(tempDir, "runtime"), { recursive: true });
const state = {
brokerHandshakeSeen: false,
commandPollSeen: false,
shellySwitchSetSeen: false,
shellyStatusSeen: false,
resultSeen: false,
commandDelivered: false,
failure: null,
requests: [],
};
const broker = createBrokerServer(state);
const shellyServer = createShellyServer(state);
let apiServer = null;
let agent = null;
let worker = null;
let agentStdout = "";
let agentStderr = "";
let workerStdout = "";
let workerStderr = "";
let agentExited = false;
let workerExited = false;
try {
const brokerPort = await listen(broker.server);
const shellyPort = await listen(shellyServer);
const workerPort = await reservePort();
const shellyAddress = `127.0.0.1:${shellyPort}`;
apiServer = createApiServer(state, brokerPort, shellyAddress);
const apiPort = await listen(apiServer);
const { containerConfigPath } = writeConfig(tempDir, apiPort, brokerPort, workerPort);
worker = spawnWorker({ ...options, workerPort });
worker.stdout.on("data", (chunk) => {
workerStdout += chunk.toString();
});
worker.stderr.on("data", (chunk) => {
workerStderr += chunk.toString();
});
worker.once("exit", () => {
workerExited = true;
});
await waitForWorker(workerPort, worker, 5000);
agent = spawnAgent({ ...options, tempDir, containerConfigPath });
agent.stdout.on("data", (chunk) => {
agentStdout += chunk.toString();
});
agent.stderr.on("data", (chunk) => {
agentStderr += chunk.toString();
});
agent.once("exit", () => {
agentExited = true;
});
const deadline = Date.now() + options.timeoutMs;
while (Date.now() < deadline && !state.failure && !agentExited && !workerExited) {
if (
state.brokerHandshakeSeen &&
state.commandPollSeen &&
state.shellySwitchSetSeen &&
state.shellyStatusSeen &&
state.resultSeen
) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
const evidence = evidenceFromState(state, agentExited, workerExited);
if (
state.failure ||
!state.brokerHandshakeSeen ||
!state.commandPollSeen ||
!state.shellySwitchSetSeen ||
!state.shellyStatusSeen ||
!state.resultSeen
) {
const error = state.failure || new Error("missing proof evidence");
error.evidence = evidence;
error.requests = state.requests;
error.agentStdout = agentStdout.slice(-3000);
error.agentStderr = agentStderr.slice(-3000);
error.workerStdout = workerStdout.slice(-3000);
error.workerStderr = workerStderr.slice(-3000);
throw error;
}
return {
evidence,
agentPath: options.agentPath,
workerPath: options.workerPath,
phpImage: options.phpImage,
tempDir,
requestCount: state.requests.length,
};
} finally {
if (agent) {
await stopChild(agent);
}
if (worker) {
await stopChild(worker);
}
for (const socket of broker.sockets) {
socket.destroy();
}
await Promise.allSettled([
closeServer(broker.server),
closeServer(shellyServer),
apiServer ? closeServer(apiServer) : Promise.resolve(),
]);
if (!options.keepTemp) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
}
async function main() {
const options = parseArgs();
if (options.help) {
printUsage();
return;
}
const result = await runProof(options);
process.stdout.write("PASS broker-connected API command triggered real LAN worker Shelly RPC signal and posted result\n");
process.stdout.write(`${JSON.stringify(result.evidence)}\n`);
process.stdout.write(`Agent: ${result.agentPath}\n`);
process.stdout.write(`LAN worker: ${result.workerPath}\n`);
process.stdout.write(`PHP image: ${result.phpImage}\n`);
if (options.keepTemp) {
process.stdout.write(`Temp dir: ${result.tempDir}\n`);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
process.stderr.write(`FAIL ${error.message}\n`);
if (error.evidence) {
process.stderr.write(`Evidence: ${JSON.stringify(error.evidence)}\n`);
}
if (error.requests) {
process.stderr.write(`Requests: ${JSON.stringify(error.requests, null, 2)}\n`);
}
if (error.agentStdout) {
process.stderr.write(`agent stdout: ${error.agentStdout}\n`);
}
if (error.agentStderr) {
process.stderr.write(`agent stderr: ${error.agentStderr}\n`);
}
if (error.workerStdout) {
process.stderr.write(`worker stdout: ${error.workerStdout}\n`);
}
if (error.workerStderr) {
process.stderr.write(`worker stderr: ${error.workerStderr}\n`);
}
process.exit(1);
});
}
+125 -54
View File
@@ -94,9 +94,23 @@ function directCaddyBaseUrl(baseUrl) {
return normalizeBaseUrl(url.toString());
}
function isLocalHost(hostname) {
const normalized = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
}
function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
const websocketUrl = new URL(String(rawUrl));
const apiUrl = new URL(normalizeBaseUrl(apiBaseUrl));
const ciBrokerPort = String(process.env.EDGE_BROKER_CI_PORT || "").trim();
if (isLocalHost(apiUrl.hostname) && websocketUrl.hostname === "edge-broker" && ciBrokerPort !== "") {
websocketUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
websocketUrl.hostname = apiUrl.hostname;
websocketUrl.port = ciBrokerPort;
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
return websocketUrl.toString();
}
if (apiUrl.hostname === "caddy" && websocketUrl.hostname === "caddy") {
websocketUrl.hostname = "edge-broker";
@@ -104,6 +118,18 @@ function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
}
if (isLocalHost(apiUrl.hostname) && ["caddy", "edge-broker"].includes(websocketUrl.hostname)) {
const brokerPath = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
websocketUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
websocketUrl.hostname = apiUrl.hostname;
websocketUrl.port = apiUrl.port;
websocketUrl.pathname = `/api/edge-broker${brokerPath}`;
}
if (isLocalHost(websocketUrl.hostname)) {
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
}
return websocketUrl.toString();
}
@@ -555,6 +581,38 @@ function summarizeStreamMessages(messages, limit = 12) {
.filter(Boolean);
}
async function readGatewayDiagnostics({ baseUrl, authToken, gatewayId, containerName }) {
const diagnostics = {};
if (gatewayId !== null && gatewayId > 0 && authToken) {
try {
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
token: authToken,
});
diagnostics.gateway = {
status: detail?.data?.status ?? null,
channelStatus: detail?.data?.channel_status ?? null,
brokerPresence: detail?.data?.metadata?.broker_presence ?? null,
brokerConnected: detail?.data?.metadata?.broker_connected ?? null,
brokerLastError: detail?.data?.metadata?.broker_last_error ?? null,
};
} catch (error) {
diagnostics.gatewayError = error instanceof Error ? error.message : String(error);
}
}
try {
const logs = await runCommand("docker", ["logs", "--tail", "120", containerName], {
allowFailure: true,
});
diagnostics.containerLogs = String(`${logs.stdout || ""}${logs.stderr || ""}`).trim().split(/\r?\n/).slice(-120);
} catch (error) {
diagnostics.containerLogError = error instanceof Error ? error.message : String(error);
}
return diagnostics;
}
async function main() {
const scriptPath = fileURLToPath(import.meta.url);
const rootDir = await resolveRootDir(scriptPath);
@@ -669,22 +727,35 @@ async function main() {
}
);
await waitForCondition(
async () => {
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
token: authToken,
});
try {
await waitForCondition(
async () => {
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
token: authToken,
});
return Boolean(
detail?.data?.channel_status?.broker?.connected
|| detail?.data?.metadata?.broker_connected
);
},
{
timeoutMs: 90_000,
message: "Gateway never established a live broker connection after install.",
}
);
return Boolean(
detail?.data?.channel_status?.broker?.connected
|| detail?.data?.metadata?.broker_connected
);
},
{
timeoutMs: 90_000,
message: "Gateway never established a live broker connection after install.",
}
);
} catch (error) {
const diagnostics = await readGatewayDiagnostics({
baseUrl,
authToken,
gatewayId,
containerName,
});
throw new Error([
error instanceof Error ? error.message : String(error),
`Broker diagnostics: ${JSON.stringify(diagnostics, null, 2)}`,
].join("\n"));
}
const WebSocketImpl = await loadWebSocketImplementation();
const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, {
@@ -707,11 +778,14 @@ async function main() {
{ timeoutMs: 15_000, message: "Gateway stream never became ready." }
);
const readyMessage = streamMessages.find((message) => message?.type === "gateway.stream.ready");
assert.equal(
Boolean(readyMessage?.connected),
true,
"Gateway stream became ready before the broker reported the gateway as connected."
await waitForSocketMessage(
streamMessages,
(message) => (
message?.type === "gateway.stream.ready" && message?.connected === true
) || (
message?.type === "presence.changed" && message?.status === "connected"
),
{ timeoutMs: 45_000, message: "Gateway stream never observed a connected broker presence." }
);
const operationResponse = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/operations`, {
@@ -739,10 +813,24 @@ async function main() {
assert.ok(operationId > 0, "Operation creation did not return an operation id.");
try {
await waitForSocketMessage(
streamMessages,
(message) => message?.type === "task.updated" && Number(message?.operationId || 0) === operationId,
{ timeoutMs: 180_000, message: "Live gateway stream never emitted task.updated for the queued operation." }
await waitForCondition(
async () => {
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
token: authToken,
});
const operation = Array.isArray(operations?.data)
? operations.data.find((item) => Number(item?.id || 0) === operationId)
: null;
return operation?.status === "COMPLETED"
|| streamMessages.some((message) => (
message?.type === "task.updated"
&& Number(message?.operationId || 0) === operationId
&& message?.operation?.status === "COMPLETED"
));
},
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
);
} catch (error) {
let operationSnapshot = null;
@@ -765,21 +853,6 @@ async function main() {
throw new Error(diagnostic);
}
await waitForCondition(
async () => {
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
token: authToken,
});
const operation = Array.isArray(operations?.data)
? operations.data.find((item) => Number(item?.id || 0) === operationId)
: null;
return operation?.status === "COMPLETED";
},
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
);
await waitForSocketMessage(
streamMessages,
(message) => message?.type === "gateway.telemetry" || message?.type === "stats.updated",
@@ -855,22 +928,20 @@ async function main() {
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
);
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
token: authToken,
});
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
: [];
await waitForCondition(
async () => {
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
token: authToken,
});
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
: [];
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
assert.ok(
shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")),
"Gateway logs page did not persist the shell transcript."
);
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
assert.ok(
timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"),
"Gateway logs page did not include the shell close audit event."
return shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell"))
&& timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED");
},
{ timeoutMs: 30_000, message: "Gateway logs page did not persist the shell transcript and close audit event." }
);
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
+16 -3
View File
@@ -18,6 +18,7 @@ cd "$repo_root"
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
project_suffix="$(date +%s)-$$"
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
log_dir=".tmp/ci-logs/$suite"
mkdir -p "$log_dir"
@@ -84,6 +85,17 @@ composer_install() {
'cd /var/www/html && composer install --no-interaction --prefer-source --no-progress'
}
configure_ci_docker_subnet() {
if [ -n "${CI_DOCKER_SUBNET:-}" ]; then
return
fi
checksum="$(printf '%s' "$COMPOSE_PROJECT_NAME" | cksum | awk '{print $1}')"
subnet_second=$((64 + ((checksum / 256) % 64)))
subnet_third=$((checksum % 256))
export CI_DOCKER_SUBNET="10.${subnet_second}.${subnet_third}.0/24"
}
cleanup() {
status="$?"
collect_logs "$status"
@@ -103,7 +115,8 @@ cleanup() {
}
trap cleanup EXIT INT TERM
retry_command "${PHP_CI_DOCKER_RETRIES:-3}" docker compose $compose_files up -d redis mysql-debug php1
configure_ci_docker_subnet
sh scripts/ci-docker-compose-up.sh redis mysql-debug php1
docker compose $compose_files exec -T php1 sh -lc '
set -eu
@@ -126,7 +139,7 @@ tar \
--exclude='./.phpunit.cache' \
--exclude='./build/logs' \
-C services/nginx/app -cf - . \
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/html -xf -
docker compose $compose_files exec -T php1 sh -lc 'rm -rf /var/www/repo-root && mkdir -p /var/www/repo-root'
tar \
@@ -135,7 +148,7 @@ tar \
Dockerfile.coolify-api \
services/php/Dockerfile \
services/php/php-fpm-pool.conf \
| docker compose $compose_files exec -T php1 tar -C /var/www/repo-root -xf -
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/repo-root -xf -
composer_install
+73 -3
View File
@@ -1,14 +1,32 @@
import process from "node:process";
import path from "node:path";
import { createHash } from "node:crypto";
import { pathToFileURL } from "node:url";
export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433";
export const EXPECTED_INSTALL_VERSION = "compose-php-agent-v3";
export const REQUIRED_MANIFEST_ARTIFACTS = [
"agent.php",
"lan-worker.php",
"auto-updater.php",
"docker-compose.gateway.yml",
"Dockerfile.edge-agent",
"Dockerfile.lan-worker",
"Dockerfile.auto-updater",
"gateway-launcher.sh",
"truckwash-edge-gateway-stack.service",
"truckwash-edge-agent.service",
];
export const INSTALLER_SCRIPT_REQUIRED_SNIPPETS = [
"/edge-agent/install-token/status",
"/edge-agent/artifacts/manifest.json",
"report_install_status",
'begin_install_phase "VERIFY_TOKEN"',
'begin_install_phase "VERIFY_ARTIFACTS"',
'begin_install_phase "WAIT_FOR_CLAIM"',
'report_install_status "FAILED"',
"verify_manifest_artifact",
EXPECTED_INSTALL_VERSION,
];
export function normalizeBaseUrl(url) {
@@ -55,13 +73,20 @@ export function buildChecks(baseUrl, installToken) {
name: "Ping",
url: `${normalizedBaseUrl}/ping`,
},
{
name: "Artifact manifest",
url: `${normalizedBaseUrl}/edge-agent/artifacts/manifest.json`,
artifactName: "manifest.json",
},
{
name: "Agent PHP artifact",
url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`,
artifactName: "agent.php",
},
{
name: "Service unit artifact",
url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`,
artifactName: "truckwash-edge-agent.service",
},
{
name: "Installer script",
@@ -70,6 +95,42 @@ export function buildChecks(baseUrl, installToken) {
];
}
export function validateArtifactManifestBody(body) {
const manifest = JSON.parse(String(body || ""));
if (manifest.version !== EXPECTED_INSTALL_VERSION) {
throw new Error(`Artifact manifest version mismatch: expected ${EXPECTED_INSTALL_VERSION}, got ${manifest.version}`);
}
if (!Array.isArray(manifest.artifacts)) {
throw new Error("Artifact manifest is missing artifacts.");
}
const byName = new Map(manifest.artifacts.map((artifact) => [artifact?.name, artifact]));
const missingArtifacts = REQUIRED_MANIFEST_ARTIFACTS.filter((artifactName) => !byName.has(artifactName));
if (missingArtifacts.length) {
throw new Error(`Artifact manifest is missing required artifacts: ${missingArtifacts.join(", ")}`);
}
return manifest;
}
export function validateArtifactBodyAgainstManifest(manifest, artifactName, body) {
const artifact = manifest?.artifacts?.find((entry) => entry?.name === artifactName);
if (!artifact) {
throw new Error(`Artifact ${artifactName} is missing from manifest.`);
}
const buffer = Buffer.isBuffer(body) ? body : Buffer.from(String(body || ""));
const sha256 = createHash("sha256").update(buffer).digest("hex");
if (sha256 !== artifact.sha256) {
throw new Error(`Artifact ${artifactName} hash mismatch: ${sha256} !== ${artifact.sha256}`);
}
if (buffer.length !== artifact.bytes) {
throw new Error(`Artifact ${artifactName} size mismatch: ${buffer.length} !== ${artifact.bytes}`);
}
return artifact;
}
export function validateInstallerScriptBody(body) {
const source = String(body || "");
const missingSnippets = INSTALLER_SCRIPT_REQUIRED_SNIPPETS.filter((snippet) => !source.includes(snippet));
@@ -107,16 +168,17 @@ export async function runSmoke({ baseUrl, installToken }) {
const checks = buildChecks(baseUrl, installToken);
const results = [];
let artifactManifest = null;
for (const check of checks) {
process.stdout.write(`[staging-smoke] GET ${check.url}\n`);
const response = await fetch(check.url);
const body = await response.text();
const body = Buffer.from(await response.arrayBuffer());
const result = {
...check,
status: response.status,
ok: response.ok,
bodyPreview: previewBody(body),
bodyPreview: previewBody(body.toString("utf8")),
};
results.push(result);
@@ -127,8 +189,16 @@ export async function runSmoke({ baseUrl, installToken }) {
);
}
if (check.name === "Artifact manifest") {
artifactManifest = validateArtifactManifestBody(body.toString("utf8"));
result.version = artifactManifest.version;
result.artifactCount = artifactManifest.artifacts.length;
}
if (artifactManifest && check.artifactName && check.artifactName !== "manifest.json") {
result.verifiedArtifact = validateArtifactBodyAgainstManifest(artifactManifest, check.artifactName, body);
}
if (check.name === "Installer script") {
result.verifiedSnippets = validateInstallerScriptBody(body);
result.verifiedSnippets = validateInstallerScriptBody(body.toString("utf8"));
}
}
@@ -3,10 +3,13 @@ import assert from "node:assert/strict";
import {
DEFAULT_STAGING_BASE_URL,
EXPECTED_INSTALL_VERSION,
INSTALLER_SCRIPT_REQUIRED_SNIPPETS,
buildChecks,
normalizeBaseUrl,
parseArgs,
validateArtifactBodyAgainstManifest,
validateArtifactManifestBody,
validateInstallerScriptBody,
} from "./staging-edge-gateway-smoke.mjs";
@@ -32,18 +35,58 @@ test("buildChecks targets the public staging endpoints", () => {
assert.deepEqual(checks.map((check) => check.url), [
"https://api.truckwash.io:4433/ping",
"https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json",
"https://api.truckwash.io:4433/edge-agent/artifacts/agent.php",
"https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service",
"https://api.truckwash.io:4433/edge-agent/install.sh?token=abc%20123",
]);
});
test("validateArtifactManifestBody requires v3 install artifacts", () => {
const artifacts = [
"agent.php",
"lan-worker.php",
"auto-updater.php",
"docker-compose.gateway.yml",
"Dockerfile.edge-agent",
"Dockerfile.lan-worker",
"Dockerfile.auto-updater",
"gateway-launcher.sh",
"truckwash-edge-gateway-stack.service",
"truckwash-edge-agent.service",
].map((name) => ({ name, sha256: "abc", bytes: 1 }));
const manifest = validateArtifactManifestBody(JSON.stringify({
version: EXPECTED_INSTALL_VERSION,
artifacts,
}));
assert.equal(manifest.version, EXPECTED_INSTALL_VERSION);
});
test("validateArtifactBodyAgainstManifest verifies size and hash", () => {
const body = Buffer.from("hello");
const manifest = {
artifacts: [{
name: "agent.php",
sha256: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
bytes: body.length,
}],
};
assert.equal(validateArtifactBodyAgainstManifest(manifest, "agent.php", body).name, "agent.php");
});
test("validateInstallerScriptBody requires install-session reporting wiring", () => {
const script = `
INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"
fetch_http "Download artifact manifest" "https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json"
report_install_status "FAILED"
begin_install_phase "VERIFY_TOKEN" "Verifying install token"
begin_install_phase "VERIFY_ARTIFACTS" "Verifying edge gateway artifacts"
begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"
verify_manifest_artifact
${EXPECTED_INSTALL_VERSION}
`;
assert.deepEqual(validateInstallerScriptBody(script), INSTALLER_SCRIPT_REQUIRED_SNIPPETS);
+15 -9
View File
@@ -12,7 +12,7 @@ export const DEFAULT_IMAGE_TAG = "truckwash-edge-agent:test-gateway";
export const DEFAULT_CONFIG_FILE_NAME = "test-gateway.json";
export const DEFAULT_HOST_API_URL = "http://localhost/api";
export const DEFAULT_CONTAINER_API_URL = "http://caddy";
export const DEFAULT_CONTAINER_BROKER_URL = "http://edge-broker:4300";
export const DEFAULT_CONTAINER_BROKER_URL = "ws://edge-broker:4300";
export const DEFAULT_INSTALL_DIR = "/opt/truckwash-edge-agent";
export const DEFAULT_RUNTIME_DIR = `${DEFAULT_INSTALL_DIR}/runtime`;
export const DEFAULT_STATE_DATABASE_PATH = `${DEFAULT_RUNTIME_DIR}/gateway-state.sqlite`;
@@ -409,14 +409,20 @@ async function startContainer({
});
if (copyConfig) {
await runCommand("docker", [
"cp",
path.join(configDir, DEFAULT_CONFIG_FILE_NAME),
`${containerName}:${containerConfigPath}`,
], {
cwd: rootDir,
stdio: "inherit",
});
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
await fs.chmod(configFilePath, 0o666).catch(() => {});
try {
await runCommand("docker", [
"cp",
configFilePath,
`${containerName}:${containerConfigPath}`,
], {
cwd: rootDir,
stdio: "inherit",
});
} finally {
await fs.chmod(configFilePath, 0o600).catch(() => {});
}
await runCommand("docker", ["start", containerName], {
cwd: rootDir,
+58
View File
@@ -754,6 +754,60 @@ export async function setRelayState(payload, fetchImpl = fetch) {
}
}
async function mapWithConcurrency(items, limit, mapper) {
const results = new Array(items.length);
let nextIndex = 0;
const workerCount = Math.max(1, Math.min(Number(limit) || 1, items.length || 1));
await Promise.all(Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await mapper(items[index], index);
}
}));
return results;
}
async function executeRelayBatch(command, handler, fetchImpl = fetch) {
const commands = Array.isArray(command?.payload?.commands)
? command.payload.commands
: Array.isArray(command?.commands)
? command.commands
: [];
const concurrency = Math.max(1, Math.min(Number(command?.payload?.concurrency || command?.concurrency || 5), 5));
const results = await mapWithConcurrency(commands, concurrency, async (entry = {}) => {
const target = String(entry.target || entry.relay || "");
const relayId = String(entry.relayId || entry.relay_id || "");
try {
const payload = await handler(entry, fetchImpl);
return {
target,
relayId,
relay_id: relayId,
ok: true,
payload,
};
} catch (error) {
return {
target,
relayId,
relay_id: relayId,
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
});
return {
batchId: command?.payload?.batchId || command?.payload?.batch_id || command?.batchId || command?.batch_id || null,
batch_id: command?.payload?.batch_id || command?.payload?.batchId || command?.batch_id || command?.batchId || null,
results,
};
}
async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch) {
if (!url) {
return null;
@@ -1416,6 +1470,10 @@ export async function handleAgentCommand(command, deps = {}) {
return await getRelayStatus(command.payload || {}, fetchImpl);
case "SET_RELAY_STATE":
return await setRelayState(command.payload || {}, fetchImpl);
case "BATCH_RELAY_STATUS":
return await executeRelayBatch(command, getRelayStatus, fetchImpl);
case "BATCH_SET_RELAY_STATE":
return await executeRelayBatch(command, setRelayState, fetchImpl);
case "RUN_UPDATE":
return await runUpdate(command.payload || {}, fetchImpl, deps);
case "UNINSTALL_AGENT":
+33
View File
@@ -127,6 +127,39 @@ test("relay status and switch commands support both Shelly RPC and legacy endpoi
assert.equal(switched.on, false);
});
test("batch relay commands return per-relay results without failing the whole batch", async () => {
const fakeFetch = async (url) => {
const value = String(url);
if (value.includes("10.1.0.31")) {
return {
ok: true,
async json() {
return { output: true };
},
};
}
throw new Error("relay offline");
};
const result = await handleAgentCommand({
commandType: "BATCH_SET_RELAY_STATE",
payload: {
batch_id: "batch-1",
commands: [
{ target: "MACHINE", relayId: "relay-machine", localIp: "10.1.0.31", channel: 0, on: true },
{ target: "EXIT", relayId: "relay-out", localIp: "10.1.0.32", channel: 0, on: true },
],
},
}, { fetchImpl: fakeFetch });
assert.equal(result.batch_id, "batch-1");
assert.equal(result.results.length, 2);
assert.equal(result.results[0].ok, true);
assert.equal(result.results[0].target, "MACHINE");
assert.equal(result.results[1].ok, false);
assert.match(result.results[1].error, /relay offline/);
});
test("Shelly discovery infers Gen3 from S3 relay model codes when generation is omitted", async () => {
const inventory = await discoverShellyDevices({ candidateIps: ["192.168.1.2"] }, async (url) => {
assert.equal(String(url), "http://192.168.1.2/shelly");
+11
View File
@@ -296,6 +296,12 @@ export function createBrokerServer(options = {}) {
? async (_gatewayId, payload = {}) => payload
: async (gatewayId, payload = {}) =>
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/logs`, payload));
const ingestMachineSignal =
options.ingestMachineSignal ||
(authMode === "stub"
? async (_gatewayId, payload = {}) => payload
: async (gatewayId, payload = {}) =>
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/selfserve/machine-signal`, payload));
const broadcastGatewayEvent = (gatewayId, message) => {
const sessionIds = gatewayStreamSessions.get(String(gatewayId));
@@ -853,6 +859,11 @@ export function createBrokerServer(options = {}) {
return;
}
if (message.type === "MACHINE_SIGNAL") {
await ingestMachineSignal(String(ws.gatewayId), message.payload || {});
return;
}
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
const sessionRecord = browserShellSessions.get(String(message.sessionId));
if (!sessionRecord) {
+45
View File
@@ -766,6 +766,51 @@ test("broker fans out telemetry, task, log, and presence updates to browser gate
await broker.close();
});
test("broker ingests self-serve machine signals from connected agents", async () => {
const machineSignals = [];
const broker = createBrokerServer({
authMode: "stub",
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
ingestMachineSignal: async (gatewayId, payload) => {
machineSignals.push({ gatewayId, payload });
return { recorded: true, lane_id: payload.lane_id };
},
});
const address = await broker.listen(0);
const port = address.port;
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
await new Promise((resolve) => agent.once("open", resolve));
agent.send(
JSON.stringify({
type: "MACHINE_SIGNAL",
payload: {
lane_id: 3,
relay_id: "machine-relay",
component: "input",
channel: 0,
event: "input.toggle_on",
state: true,
},
})
);
await waitFor(() => machineSignals.length === 1, { description: "machine signal ingestion" });
assert.equal(machineSignals[0].gatewayId, "701");
assert.deepEqual(machineSignals[0].payload, {
lane_id: 3,
relay_id: "machine-relay",
component: "input",
channel: 0,
event: "input.toggle_on",
state: true,
});
agent.terminate();
await broker.close();
});
test("broker survives telemetry ingestion failures for stale gateways", async () => {
const broker = createBrokerServer({
authMode: "stub",
File diff suppressed because one or more lines are too long
@@ -12452,3 +12452,875 @@
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55074 Closing
[Tue Jun 2 14:17:06 2026] 127.0.0.1:34490 Accepted
[Tue Jun 2 14:17:06 2026] 127.0.0.1:34490 Closing
[Thu Jun 4 05:49:23 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41145) started
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56234 Accepted
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56234 Closing
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56242 Accepted
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56242 Closing
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56250 Accepted
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56250 Closing
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56258 Accepted
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56258 Closing
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56272 Accepted
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56272 Closing
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56278 Accepted
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56278 Closing
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56288 Accepted
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56288 Closing
[Thu Jun 4 05:49:46 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45671) started
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37442 Accepted
[Thu Jun 4 05:49:47 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37442 Closing
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37444 Accepted
[Thu Jun 4 05:49:47 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37444 Closing
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37448 Accepted
[Thu Jun 4 05:49:47 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37448 Closing
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37462 Accepted
[Thu Jun 4 05:49:47 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37462 Closing
[Thu Jun 4 05:49:57 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33783) started
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59700 Accepted
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59700 Closing
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59702 Accepted
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59702 Closing
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59708 Accepted
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59708 Closing
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59722 Accepted
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59722 Closing
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59732 Accepted
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 05:49:58 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 05:49:58 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59732 Closing
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59738 Accepted
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59738 Closing
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59752 Accepted
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59752 Closing
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59756 Accepted
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 05:49:58 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 05:49:58 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59756 Closing
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59762 Accepted
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59762 Closing
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59772 Accepted
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59772 Closing
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59784 Accepted
[Thu Jun 4 05:49:59 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59784 Closing
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59790 Accepted
[Thu Jun 4 05:49:59 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59790 Closing
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59806 Accepted
[Thu Jun 4 05:49:59 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59806 Closing
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59810 Accepted
[Thu Jun 4 05:49:59 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59810 Closing
[Thu Jun 4 06:06:51 2026] PHP 8.2.15 Development Server (http://127.0.0.1:36081) started
[Thu Jun 4 06:06:51 2026] 127.0.0.1:34670 Accepted
[Thu Jun 4 06:06:51 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:06:51 2026] 127.0.0.1:34670 Closing
[Thu Jun 4 06:06:51 2026] 127.0.0.1:34674 Accepted
[Thu Jun 4 06:06:51 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:06:51 2026] 127.0.0.1:34674 Closing
[Thu Jun 4 06:06:51 2026] 127.0.0.1:58440 Accepted
[Thu Jun 4 06:06:51 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:06:51 2026] 127.0.0.1:58440 Closing
[Thu Jun 4 06:06:51 2026] 127.0.0.1:58454 Accepted
[Thu Jun 4 06:06:51 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58454 Closing
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58466 Accepted
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:06:52 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 06:06:52 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58466 Closing
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58478 Accepted
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58478 Closing
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58492 Accepted
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58492 Closing
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58502 Accepted
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:06:52 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 06:06:52 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58502 Closing
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58512 Accepted
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58512 Closing
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58522 Accepted
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58522 Closing
[Thu Jun 4 06:07:21 2026] 127.0.0.1:56322 Accepted
[Thu Jun 4 06:07:21 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:07:21 2026] 127.0.0.1:56322 Closing
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56336 Accepted
[Thu Jun 4 06:07:22 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56336 Closing
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56352 Accepted
[Thu Jun 4 06:07:22 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56352 Closing
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56362 Accepted
[Thu Jun 4 06:07:22 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56362 Closing
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56374 Accepted
[Thu Jun 4 06:07:22 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56374 Closing
[Thu Jun 4 06:18:18 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45139) started
[Thu Jun 4 06:18:18 2026] 127.0.0.1:40632 Accepted
[Thu Jun 4 06:18:18 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:18:18 2026] 127.0.0.1:40632 Closing
[Thu Jun 4 06:18:18 2026] 127.0.0.1:40646 Accepted
[Thu Jun 4 06:18:18 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40646 Closing
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40658 Accepted
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40658 Closing
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40672 Accepted
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40672 Closing
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40674 Accepted
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:18:19 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 06:18:19 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40674 Closing
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40684 Accepted
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40684 Closing
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40686 Accepted
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40686 Closing
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40700 Accepted
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:18:19 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 06:18:19 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40700 Closing
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40702 Accepted
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40702 Closing
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40714 Accepted
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40714 Closing
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40722 Accepted
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40722 Closing
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40736 Accepted
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40736 Closing
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40752 Accepted
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40752 Closing
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40760 Accepted
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40760 Closing
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40764 Accepted
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40764 Closing
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40768 Accepted
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:18:20 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 06:18:20 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40768 Closing
[Thu Jun 4 06:18:21 2026] 127.0.0.1:40774 Accepted
[Thu Jun 4 06:18:21 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:18:21 2026] 127.0.0.1:40774 Closing
[Thu Jun 4 06:31:38 2026] PHP 8.2.15 Development Server (http://127.0.0.1:40389) started
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60850 Accepted
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60850 Closing
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60856 Accepted
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60856 Closing
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60858 Accepted
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60858 Closing
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60872 Accepted
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60872 Closing
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60874 Accepted
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:31:38 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 06:31:38 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60874 Closing
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60890 Accepted
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60890 Closing
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60904 Accepted
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60904 Closing
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60916 Accepted
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:31:38 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 06:31:38 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60916 Closing
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60926 Accepted
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60926 Closing
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60936 Accepted
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60936 Closing
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60946 Accepted
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60946 Closing
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60954 Accepted
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60954 Closing
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60960 Accepted
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60960 Closing
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60964 Accepted
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60964 Closing
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60966 Accepted
[Thu Jun 4 06:31:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60966 Closing
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60982 Accepted
[Thu Jun 4 06:31:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:31:40 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 06:31:40 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60982 Closing
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60990 Accepted
[Thu Jun 4 06:31:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60990 Closing
[Thu Jun 4 06:31:40 2026] 127.0.0.1:32770 Accepted
[Thu Jun 4 06:31:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Thu Jun 4 06:31:40 2026] 127.0.0.1:32770 Closing
[Fri Jun 12 12:04:15 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44515) started
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34204 Accepted
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34204 Closing
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34210 Accepted
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34210 Closing
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34214 Accepted
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34214 Closing
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34226 Accepted
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34226 Closing
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34228 Accepted
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34228 Closing
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34238 Accepted
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34238 Closing
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34244 Accepted
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34244 Closing
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34260 Accepted
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34260 Closing
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34274 Accepted
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34274 Closing
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34280 Accepted
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34280 Closing
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34288 Accepted
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34288 Closing
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34290 Accepted
[Fri Jun 12 12:04:17 2026] 127.0.0.1:34290 Closing
[Fri Jun 12 12:04:17 2026] 127.0.0.1:34296 Accepted
[Fri Jun 12 12:04:18 2026] 127.0.0.1:34296 Closing
[Fri Jun 12 12:04:18 2026] 127.0.0.1:34304 Accepted
[Fri Jun 12 12:04:19 2026] 127.0.0.1:34304 Closing
[Fri Jun 12 12:04:19 2026] 127.0.0.1:34314 Accepted
[Fri Jun 12 12:04:20 2026] 127.0.0.1:34314 Closing
[Fri Jun 12 12:19:47 2026] PHP 8.2.15 Development Server (http://127.0.0.1:35009) started
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57422 Accepted
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57422 Closing
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57428 Accepted
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57428 Closing
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57444 Accepted
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57444 Closing
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57454 Accepted
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57454 Closing
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57470 Accepted
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57470 Closing
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57472 Accepted
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57472 Closing
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57478 Accepted
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57478 Closing
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57488 Accepted
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57488 Closing
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57496 Accepted
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57496 Closing
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57510 Accepted
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57510 Closing
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57520 Accepted
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57520 Closing
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57522 Accepted
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57522 Closing
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57536 Accepted
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57536 Closing
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57548 Accepted
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57548 Closing
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57550 Accepted
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57550 Closing
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57566 Accepted
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57566 Closing
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57582 Accepted
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57582 Closing
[Fri Jun 12 12:23:27 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33981) started
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56426 Accepted
[Fri Jun 12 12:23:27 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:23:27 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56426 Closing
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56432 Accepted
[Fri Jun 12 12:23:27 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:23:27 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56432 Closing
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56438 Accepted
[Fri Jun 12 12:23:27 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:23:27 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56438 Closing
[Fri Jun 12 12:39:31 2026] PHP 8.2.15 Development Server (http://127.0.0.1:43643) started
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58744 Accepted
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58744 Closing
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58758 Accepted
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58758 Closing
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58764 Accepted
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58764 Closing
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58774 Accepted
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58774 Closing
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58782 Accepted
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58782 Closing
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58788 Accepted
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58788 Closing
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58804 Accepted
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58804 Closing
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58818 Accepted
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58818 Closing
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58830 Accepted
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58830 Closing
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58834 Accepted
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58834 Closing
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58838 Accepted
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58838 Closing
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58846 Accepted
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58846 Closing
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58862 Accepted
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58862 Closing
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58872 Accepted
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58872 Closing
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58884 Accepted
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58884 Closing
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58890 Accepted
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58890 Closing
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58896 Accepted
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58896 Closing
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58898 Accepted
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58898 Closing
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58912 Accepted
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58912 Closing
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58918 Accepted
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58918 Closing
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58932 Accepted
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58932 Closing
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58944 Accepted
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58944 Closing
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58956 Accepted
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58956 Closing
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58962 Accepted
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58962 Closing
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58976 Accepted
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58976 Closing
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58982 Accepted
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58982 Closing
[Fri Jun 12 12:39:34 2026] 127.0.0.1:58998 Accepted
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:34 2026] 127.0.0.1:58998 Closing
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59004 Accepted
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59004 Closing
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59012 Accepted
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59012 Closing
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59028 Accepted
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59028 Closing
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59040 Accepted
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59040 Closing
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59046 Accepted
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59046 Closing
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59052 Accepted
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59052 Closing
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59062 Accepted
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59062 Closing
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59074 Accepted
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59074 Closing
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59078 Accepted
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59078 Closing
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59080 Accepted
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59080 Closing
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59090 Accepted
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59090 Closing
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59100 Accepted
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59100 Closing
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59102 Accepted
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59102 Closing
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59106 Accepted
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59106 Closing
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59120 Accepted
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59120 Closing
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59122 Accepted
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59122 Closing
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59136 Accepted
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59136 Closing
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59144 Accepted
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59144 Closing
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59158 Accepted
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59158 Closing
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59162 Accepted
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59162 Closing
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59176 Accepted
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59176 Closing
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59190 Accepted
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59190 Closing
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59202 Accepted
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59202 Closing
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59204 Accepted
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59204 Closing
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59212 Accepted
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59212 Closing
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59222 Accepted
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59222 Closing
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59224 Accepted
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59224 Closing
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59226 Accepted
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59226 Closing
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59230 Accepted
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59230 Closing
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59232 Accepted
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59232 Closing
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59248 Accepted
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59248 Closing
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59254 Accepted
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59254 Closing
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59270 Accepted
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59270 Closing
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59276 Accepted
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59276 Closing
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59290 Accepted
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59290 Closing
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59300 Accepted
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59300 Closing
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59316 Accepted
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59316 Closing
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59326 Accepted
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59326 Closing
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59330 Accepted
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59330 Closing
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59332 Accepted
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59332 Closing
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59338 Accepted
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59338 Closing
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59350 Accepted
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59350 Closing
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59356 Accepted
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59356 Closing
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59366 Accepted
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59366 Closing
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59382 Accepted
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59382 Closing
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59392 Accepted
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59392 Closing
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59408 Accepted
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59408 Closing
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59410 Accepted
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59410 Closing
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59426 Accepted
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59426 Closing
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59430 Accepted
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59430 Closing
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59444 Accepted
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59444 Closing
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59452 Accepted
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59452 Closing
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59458 Accepted
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59458 Closing
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59462 Accepted
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59462 Closing
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59470 Accepted
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59470 Closing
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59484 Accepted
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59484 Closing
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59488 Accepted
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59488 Closing
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59496 Accepted
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59496 Closing
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59512 Accepted
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59512 Closing
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59516 Accepted
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59516 Closing
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59524 Accepted
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59524 Closing
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59526 Accepted
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59526 Closing
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59534 Accepted
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59534 Closing
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59536 Accepted
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59536 Closing
[Fri Jun 12 12:39:41 2026] 127.0.0.1:42172 Accepted
[Fri Jun 12 12:39:41 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:39:41 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:39:41 2026] 127.0.0.1:42172 Closing
[Fri Jun 12 12:41:48 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45327) started
[Fri Jun 12 12:41:48 2026] 127.0.0.1:49112 Accepted
[Fri Jun 12 12:41:48 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:41:48 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:41:48 2026] 127.0.0.1:49112 Closing
[Fri Jun 12 12:42:48 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44757) started
[Fri Jun 12 12:42:48 2026] 127.0.0.1:44444 Accepted
[Fri Jun 12 12:42:48 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:42:48 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:42:48 2026] 127.0.0.1:44444 Closing
[Fri Jun 12 12:43:43 2026] PHP 8.2.15 Development Server (http://127.0.0.1:43731) started
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40338 Accepted
[Fri Jun 12 12:43:43 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:43:43 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:43:43 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40338 Closing
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40354 Accepted
[Fri Jun 12 12:43:43 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:43:43 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:43:43 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40354 Closing
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40358 Accepted
[Fri Jun 12 12:43:43 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:43:43 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:43:43 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40358 Closing
[Fri Jun 12 12:53:28 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41773) started
[Fri Jun 12 12:53:29 2026] 127.0.0.1:59496 Accepted
[Fri Jun 12 12:53:29 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:53:29 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:53:29 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 12:53:29 2026] 127.0.0.1:59496 Closing
[Fri Jun 12 12:53:29 2026] 127.0.0.1:59506 Accepted
[Fri Jun 12 12:53:29 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 12:53:29 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 12:53:29 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 12:53:29 2026] 127.0.0.1:59506 Closing
[Fri Jun 12 13:42:40 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45259) started
[Fri Jun 12 13:42:40 2026] 127.0.0.1:50638 Accepted
[Fri Jun 12 13:42:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 13:42:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 13:42:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 13:42:40 2026] 127.0.0.1:50638 Closing
[Fri Jun 12 13:42:40 2026] 127.0.0.1:50644 Accepted
[Fri Jun 12 13:42:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 13:42:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 13:42:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 13:42:40 2026] 127.0.0.1:50644 Closing
[Fri Jun 12 13:45:11 2026] PHP 8.2.15 Development Server (http://127.0.0.1:43693) started
[Fri Jun 12 13:45:12 2026] 127.0.0.1:59572 Accepted
[Fri Jun 12 13:45:12 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 13:45:12 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 13:45:12 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 13:45:12 2026] 127.0.0.1:59572 Closing
[Fri Jun 12 13:45:12 2026] 127.0.0.1:59578 Accepted
[Fri Jun 12 13:45:12 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 13:45:12 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 13:45:12 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 13:45:12 2026] 127.0.0.1:59578 Closing
[Fri Jun 12 13:45:12 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37353) started
[Fri Jun 12 13:45:12 2026] 127.0.0.1:37666 Accepted
[Fri Jun 12 13:45:12 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 13:45:12 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 13:45:12 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 13:45:12 2026] 127.0.0.1:37666 Closing
[Fri Jun 12 13:45:12 2026] 127.0.0.1:37676 Accepted
[Fri Jun 12 13:45:12 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 13:45:12 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 13:45:12 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 13:45:12 2026] 127.0.0.1:37676 Closing
[Fri Jun 12 13:49:21 2026] PHP 8.2.15 Development Server (http://127.0.0.1:32781) started
[Fri Jun 12 13:49:21 2026] 127.0.0.1:57936 Accepted
[Fri Jun 12 13:49:21 2026] 127.0.0.1:57936 Closing
[Fri Jun 12 13:49:21 2026] 127.0.0.1:57940 Accepted
[Fri Jun 12 13:49:21 2026] 127.0.0.1:57940 Closing
[Fri Jun 12 13:49:22 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33723) started
[Fri Jun 12 13:49:22 2026] 127.0.0.1:60016 Accepted
[Fri Jun 12 13:49:22 2026] 127.0.0.1:60016 Closing
[Fri Jun 12 13:49:22 2026] 127.0.0.1:60018 Accepted
[Fri Jun 12 13:49:22 2026] 127.0.0.1:60018 Closing
[Fri Jun 12 13:49:57 2026] PHP 8.2.15 Development Server (http://127.0.0.1:34549) started
[Fri Jun 12 13:49:57 2026] 127.0.0.1:59336 Accepted
[Fri Jun 12 13:49:57 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 13:49:57 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 13:49:57 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 13:49:57 2026] 127.0.0.1:59336 Closing
[Fri Jun 12 13:49:57 2026] 127.0.0.1:59342 Accepted
[Fri Jun 12 13:49:57 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 13:49:57 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 13:49:57 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 13:49:58 2026] 127.0.0.1:59342 Closing
[Fri Jun 12 13:49:58 2026] PHP 8.2.15 Development Server (http://127.0.0.1:36181) started
[Fri Jun 12 13:49:58 2026] 127.0.0.1:39290 Accepted
[Fri Jun 12 13:49:58 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 13:49:58 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 13:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 13:49:58 2026] 127.0.0.1:39290 Closing
[Fri Jun 12 13:49:58 2026] 127.0.0.1:39302 Accepted
[Fri Jun 12 13:49:58 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 13:49:58 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 13:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 13:49:58 2026] 127.0.0.1:39302 Closing
[Fri Jun 12 14:03:34 2026] PHP 8.2.15 Development Server (http://127.0.0.1:40101) started
[Fri Jun 12 14:03:34 2026] 127.0.0.1:59406 Accepted
[Fri Jun 12 14:03:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 14:03:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 14:03:34 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 14:03:34 2026] 127.0.0.1:59406 Closing
[Fri Jun 12 14:03:34 2026] 127.0.0.1:59414 Accepted
[Fri Jun 12 14:03:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 14:03:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 14:03:34 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 14:03:34 2026] 127.0.0.1:59414 Closing
[Fri Jun 12 14:03:35 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33543) started
[Fri Jun 12 14:03:35 2026] 127.0.0.1:60992 Accepted
[Fri Jun 12 14:03:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 14:03:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 14:03:35 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 14:03:35 2026] 127.0.0.1:60992 Closing
[Fri Jun 12 14:03:35 2026] 127.0.0.1:60994 Accepted
[Fri Jun 12 14:03:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 14:03:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 14:03:35 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 14:03:35 2026] 127.0.0.1:60994 Closing
[Fri Jun 12 14:05:57 2026] PHP 8.2.15 Development Server (http://127.0.0.1:46061) started
[Fri Jun 12 14:05:57 2026] 127.0.0.1:38780 Accepted
[Fri Jun 12 14:05:57 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 14:05:57 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 14:05:57 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 14:05:57 2026] 127.0.0.1:38780 Closing
[Fri Jun 12 14:05:57 2026] 127.0.0.1:38792 Accepted
[Fri Jun 12 14:05:57 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 14:05:57 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 14:05:57 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 14:05:57 2026] 127.0.0.1:38792 Closing
[Fri Jun 12 14:05:57 2026] PHP 8.2.15 Development Server (http://127.0.0.1:34023) started
[Fri Jun 12 14:05:58 2026] 127.0.0.1:59736 Accepted
[Fri Jun 12 14:05:58 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 14:05:58 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 14:05:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 14:05:58 2026] 127.0.0.1:59736 Closing
[Fri Jun 12 14:05:58 2026] 127.0.0.1:59748 Accepted
[Fri Jun 12 14:05:58 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
[Fri Jun 12 14:05:58 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
[Fri Jun 12 14:05:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
[Fri Jun 12 14:05:58 2026] 127.0.0.1:59748 Closing
@@ -137,6 +137,10 @@ class customer_mass_import_service
if ($cvrLength < 8 || $cvrLength > 20) {
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400);
}
if ($normalized['ean'] !== null && strlen((string)$normalized['ean']) > 13) {
throw new \RuntimeException('EAN must be at most 13 digits.', 400);
}
}
protected function normalizePositiveInt(mixed $value): ?int
@@ -0,0 +1,98 @@
<?php
namespace classes;
use RuntimeException;
class customer_order_product_policy
{
public const ONLY_TANKCLEANING_ATTRIBUTE = 'onlyTankCleaning';
public const ONLY_TANKCLEANING_MESSAGE = 'Only tankcleaning customers can only have tankcleaning products in their orders.';
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
{
$message = self::orderProductViolationMessage($orderId, $productId);
if ($message !== null) {
throw new RuntimeException($message);
}
}
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
{
$context = self::loadOrderProductContext($orderId, $productId);
if ($context === null) {
return null;
}
if ((int)($context['product_id'] ?? 0) < 1) {
return null;
}
return self::onlyTankCleaningViolation((bool)((int)($context['has_only_tank_cleaning'] ?? 0)), $context)
? self::ONLY_TANKCLEANING_MESSAGE
: null;
}
public static function onlyTankCleaningViolation(bool $customerHasOnlyTankCleaning, array $productRow): bool
{
return $customerHasOnlyTankCleaning && !self::isTankCleaningProductRow($productRow);
}
public static function isTankCleaningProductRow(array $row): bool
{
return (int)($row['product_category'] ?? $row['category'] ?? 0) === 5
|| self::rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
}
private static function loadOrderProductContext(int $orderId, int $productId): ?array
{
global $db;
if ($orderId < 1 || $productId < 1) {
return null;
}
$sql = "
SELECT
o.id AS order_id,
o.customer_id AS customer_number,
p.id AS product_id,
p.name AS product_name,
p.category AS product_category,
c.name AS category_name,
MAX(CASE WHEN ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "' THEN 1 ELSE 0 END) AS has_only_tank_cleaning
FROM orders o
LEFT JOIN products p ON p.id = {$productId}
LEFT JOIN categories c ON c.id = p.category
LEFT JOIN users u ON u.customer_number = o.customer_id
LEFT JOIN customer_attributes ca ON ca.user_id = u.id
AND ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "'
WHERE o.id = {$orderId}
GROUP BY o.id, o.customer_id, p.id, p.name, p.category, c.name
LIMIT 1
";
$result = $db->query($sql);
if (!$result || $result->num_rows < 1) {
return null;
}
$row = $result->fetch_assoc();
return is_array($row) ? $row : null;
}
private static function rowMatchesProductTerms(array $row, array $terms): bool
{
$haystack = strtolower(trim(
(string)($row['product_name'] ?? $row['name'] ?? '') . ' ' .
(string)($row['category_name'] ?? '')
));
foreach ($terms as $term) {
if ($term !== '' && str_contains($haystack, strtolower($term))) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,158 @@
<?php
namespace classes;
use objects\orders_o;
use objects\products_o;
use objects\users_o;
class customer_product_rule_service
{
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
private const ADDON_CATEGORY_ID = 4;
private const TANK_CLEANING_CATEGORY_ID = 5;
/**
* @return array{rule:string,message:string}|null
*/
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
{
$order = (new orders_o())->getOrderById($orderId);
if (!$order->exists()) {
return null;
}
$product = (new products_o())->getProductById($productId);
if (!$product->exists()) {
return null;
}
$customer = (new users_o())->getUserByCustomerNumber((int)$order->customer_id->value());
if (!$customer->exists()) {
return null;
}
$categoryId = (int)$product->category->value();
$categoryName = $this->categoryName($categoryId);
$searchableProduct = $this->searchableProductText($product, $categoryName);
$isTankCleaningProduct = $this->isTankCleaningProduct($categoryId, $searchableProduct);
if ($customer->doesUserHaveAttribute('restrictAdditionalServices')
&& $this->isAdditionalServiceProduct($orderId, $relatedItemId, $categoryId, $searchableProduct)) {
return $this->violation('restrictAdditionalServices');
}
if ($customer->doesUserHaveAttribute('restrictTankCleaning') && $isTankCleaningProduct) {
return $this->violation('restrictTankCleaning');
}
if ($customer->doesUserHaveAttribute('onlyTankCleaning') && !$isTankCleaningProduct) {
return $this->violation('onlyTankCleaning');
}
if ($customer->doesUserHaveAttribute('restrictSpotFree')
&& $this->containsAny($searchableProduct, ['spot free', 'spotfree'])) {
return $this->violation('restrictSpotFree');
}
if ($customer->doesUserHaveAttribute('restrictInteriorCleaning')
&& $this->containsAny($searchableProduct, ['interior', 'indvendig'])) {
return $this->violation('restrictInteriorCleaning');
}
return null;
}
/**
* @return array{rule:string,message:string}
*/
private function violation(string $rule): array
{
return [
'rule' => $rule,
'message' => self::BLOCK_MESSAGE,
];
}
private function isAdditionalServiceProduct(int $orderId, ?int $relatedItemId, int $categoryId, string $searchableProduct): bool
{
if ($relatedItemId !== null && $relatedItemId > 0) {
return true;
}
if ($categoryId === self::ADDON_CATEGORY_ID) {
return true;
}
if ($this->containsAny($searchableProduct, ['add-on', 'add on', 'addon', 'tilvalg'])) {
return true;
}
return $this->countStandaloneOrderItems($orderId) > 0;
}
private function isTankCleaningProduct(int $categoryId, string $searchableProduct): bool
{
if ($categoryId === self::TANK_CLEANING_CATEGORY_ID) {
return true;
}
return $this->containsAny($searchableProduct, ['tank cleaning', 'tankcleaning', 'tankrens', 'tank rens']);
}
private function searchableProductText(products_o $product, string $categoryName): string
{
return strtolower(trim((string)$product->name->value() . ' ' . $categoryName));
}
/**
* @param array<int, string> $terms
*/
private function containsAny(string $value, array $terms): bool
{
foreach ($terms as $term) {
if ($term !== '' && str_contains($value, $term)) {
return true;
}
}
return false;
}
private function categoryName(int $categoryId): string
{
global $db;
if ($categoryId <= 0) {
return '';
}
$result = $db->query('SELECT name FROM categories WHERE id = ' . $categoryId . ' LIMIT 1');
if (!$result || $result->num_rows === 0) {
return '';
}
$row = $result->fetch_assoc();
return strtolower((string)($row['name'] ?? ''));
}
private function countStandaloneOrderItems(int $orderId): int
{
global $db;
$result = $db->query(
'SELECT COUNT(*) AS item_count
FROM order_items
WHERE order_id = ' . $orderId . '
AND deleted_at IS NULL
AND (related_item_id IS NULL OR related_item_id = 0)'
);
if (!$result) {
return 0;
}
$row = $result->fetch_assoc();
return (int)($row['item_count'] ?? 0);
}
}
@@ -34,6 +34,14 @@ class departments_schema_bootstrap
);
}
if (!self::columnExists($db, 'departments', 'custom_pricing_only')) {
$db->query(
"ALTER TABLE departments
ADD COLUMN custom_pricing_only TINYINT(1) NOT NULL DEFAULT 0
AFTER archived"
);
}
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
$db->query(
"ALTER TABLE departments
+29 -1
View File
@@ -172,7 +172,8 @@ class economic implements economic_i
string $email,
int $phone,
?int $mobile_phone = null,
object|array|null $company_information = null
object|array|null $company_information = null,
?string $ean = null
): object
{
$payload = [
@@ -196,10 +197,37 @@ class economic implements economic_i
];
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information));
$normalized_ean = self::normalizeCustomerEan($ean);
if ($normalized_ean !== null) {
$payload['ean'] = $normalized_ean;
}
return $this->customers->customers->create($payload);
}
public static function normalizeCustomerEan(mixed $value): ?string
{
if ($value === null) {
return null;
}
$digits = preg_replace('/\D+/', '', (string)$value);
if (!is_string($digits)) {
return null;
}
$digits = trim($digits);
if ($digits === '') {
return null;
}
if (strlen($digits) > 13) {
throw new \InvalidArgumentException('EAN must be at most 13 digits.');
}
return $digits;
}
private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
{
if ($company_information === null) {
@@ -240,7 +240,13 @@ class economic_transfer_executor
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
);
return $collected_order_invoices->asArray();
$result = $collected_order_invoices->asArray();
$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();
if ($transfer_metrics !== null) {
$result['economic_transfer_metrics'] = $transfer_metrics;
}
return $result;
}
/**
@@ -389,6 +389,15 @@ class economic_v2_distribution_service
}
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at);
if ($discount_row === null) {
continue;
}
if (array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
$fixed_price = (float)$discount_row['fixed_price'];
$order_discount_total += (($base_price - $fixed_price) * $quantity);
continue;
}
$discount_percentage = (float)($discount_row['discount'] ?? 0);
if ($discount_percentage <= 0) {
continue;
@@ -1520,6 +1529,12 @@ class economic_v2_distribution_service
}
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp);
if ($discount_row !== null && array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
$line_price = ((float)$discount_row['fixed_price']) * $quantity;
$total += $line_price;
continue;
}
$discount_percentage = (float)($discount_row['discount'] ?? 0);
if ($discount_percentage > 0) {
$line_price *= (1 - ($discount_percentage / 100));
@@ -1537,7 +1552,10 @@ class economic_v2_distribution_service
}
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp);
if ($direct !== null && (int)($direct['discount'] ?? 0) > 0) {
if ($direct !== null && (
(array_key_exists('fixed_price', $direct) && $direct['fixed_price'] !== null)
|| (int)($direct['discount'] ?? 0) > 0
)) {
return $this->discount_resolution_cache[$cache_key] = $direct;
}
@@ -65,6 +65,7 @@ class economic_v2_schema_bootstrap
is_category TINYINT(1) NOT NULL,
object_id VARCHAR(64) NOT NULL,
discount INT NOT NULL,
fixed_price INT NULL DEFAULT NULL,
effective_from DATETIME NOT NULL,
effective_to DATETIME NULL,
source VARCHAR(64) NOT NULL DEFAULT 'live',
@@ -83,6 +84,14 @@ class economic_v2_schema_bootstrap
$db->query($sql);
}
if (!self::tableHasColumn('customer_discount_override_versions', 'fixed_price')) {
$db->query(
"ALTER TABLE customer_discount_override_versions
ADD COLUMN fixed_price INT NULL DEFAULT NULL
AFTER discount"
);
}
self::$initialized = true;
}
@@ -106,4 +115,3 @@ class economic_v2_schema_bootstrap
return ((int)($row['c'] ?? 0)) > 0;
}
}
@@ -135,7 +135,8 @@ class economic_v2_versioning_service
string $source = 'live.discount_override',
float $confidence = 1.0,
bool $inferred = false,
array $metadata = []
array $metadata = [],
?int $fixed_price = null
): array {
$identity = [
'user_id' => $user_id,
@@ -144,7 +145,7 @@ class economic_v2_versioning_service
'object_id' => (string)$object_id,
];
if ($discount === null || (int)$discount === 0) {
if (($discount === null || (int)$discount === 0) && $fixed_price === null) {
return $this->closeActiveVersion(
'customer_discount_override_versions',
$identity,
@@ -161,6 +162,7 @@ class economic_v2_versioning_service
$identity,
[
'discount' => (int)$discount,
'fixed_price' => $is_category ? null : $fixed_price,
],
$this->normalizeDatetime($effective_from),
$source,
@@ -411,8 +413,11 @@ class economic_v2_versioning_service
}
// Discount overrides current state.
price_overrides_schema_bootstrap::ensureColumns();
$has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at');
$has_override_fixed_price = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'fixed_price');
$discount_cols = 'po.user_id, u.customer_number, po.is_category, po.product_or_category_id, po.percentage' .
($has_override_fixed_price ? ', po.fixed_price' : '') .
($has_override_created_at ? ', po.created_at' : '');
$discount_rows = $this->fetchAll(
"SELECT $discount_cols
@@ -434,7 +439,8 @@ class economic_v2_versioning_service
'backfill.current_discount_override',
$confidence,
true,
['table' => 'price_overrides']
['table' => 'price_overrides'],
$has_override_fixed_price && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null
);
$this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop');
}
@@ -707,4 +713,3 @@ class economic_v2_versioning_service
$bucket[$action]++;
}
}
@@ -92,12 +92,17 @@ class error_report_service
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'] : [];
$storedScreenshot = $this->storeOptionalScreenshot($payload['screenshot'] ?? null, $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);
$runtimeContext['screenshot_attachment'] = [
'status' => $storedScreenshot['status'],
'attached' => $storedScreenshot['key'] !== '',
'mime_type' => $storedScreenshot['mime_type'] !== '' ? $storedScreenshot['mime_type'] : null,
'size_bytes' => (int)$storedScreenshot['size_bytes'],
];
$this->execute(
"INSERT INTO error_reports (
@@ -295,6 +300,67 @@ class error_report_service
return $value === true || $value === 1 || $value === '1' || $value === 'true';
}
private function storeOptionalScreenshot(mixed $value, array $context): array
{
if (!is_scalar($value) && !$value instanceof \Stringable && $value !== null) {
return $this->emptyScreenshotAttachment('invalid');
}
$dataUri = trim((string)($value ?? ''));
if ($dataUri === '') {
return $this->emptyScreenshotAttachment($this->contextScreenshotStatus($context) ?? 'not_provided');
}
try {
$screenshot = self::decodeScreenshotDataUri($dataUri);
} catch (RuntimeException $exception) {
$message = strtolower($exception->getMessage());
return $this->emptyScreenshotAttachment(str_contains($message, 'too large') ? 'too_large' : 'invalid');
}
try {
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
} catch (Throwable) {
return $this->emptyScreenshotAttachment('storage_failed');
}
return [
'key' => (string)($storedScreenshot['key'] ?? ''),
'mime_type' => (string)($storedScreenshot['mime_type'] ?? $screenshot['mime_type']),
'size_bytes' => (int)($storedScreenshot['size_bytes'] ?? $screenshot['size_bytes']),
'status' => 'stored',
];
}
private function emptyScreenshotAttachment(string $status): array
{
return [
'key' => '',
'mime_type' => '',
'size_bytes' => 0,
'status' => $status,
];
}
private function contextScreenshotStatus(array $context): ?string
{
$attachment = $context['screenshot_attachment'] ?? null;
$status = is_array($attachment) ? ($attachment['status'] ?? null) : null;
$status ??= $context['screenshot_capture_status'] ?? $context['screenshot_status'] ?? null;
return $this->normalizeEmptyScreenshotStatus($status);
}
private function normalizeEmptyScreenshotStatus(mixed $status): ?string
{
$status = strtolower(trim((string)$status));
if (in_array($status, ['capture_failed', 'not_provided'], true)) {
return $status;
}
return null;
}
private function runtimeContext(array $payload, array $context): array
{
return [
@@ -432,6 +498,10 @@ class error_report_service
private function publicReport(array $row, bool $includeDetail): array
{
$screenshotMimeType = trim((string)($row['screenshot_mime_type'] ?? ''));
$screenshotSizeBytes = isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0;
$hasScreenshot = $screenshotMimeType !== '' && $screenshotSizeBytes > 0;
$report = [
'id' => (int)$row['id'],
'status' => (string)$row['status'],
@@ -449,10 +519,10 @@ class error_report_service
'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,
],
'screenshot' => $hasScreenshot ? [
'mime_type' => $screenshotMimeType,
'size_bytes' => $screenshotSizeBytes,
] : null,
'answers' => [
'before_error' => $row['before_error'] ?? '',
'expected' => $row['expected'] ?? '',
@@ -467,8 +537,11 @@ class error_report_service
];
if ($includeDetail) {
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? ''));
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null;
if ($hasScreenshot) {
$objectKey = trim((string)($row['screenshot_object_key'] ?? ''));
$report['screenshot']['url'] = $this->store->screenshotUrl($objectKey);
$report['screenshot']['object_key'] = $objectKey !== '' ? $objectKey : null;
}
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
@@ -38,6 +38,8 @@ class gateway_shelly_transport implements shelly_transport_i
return match ($endpoint) {
'/v2/devices/api/get' => $this->handleGetStates($department_id, $data),
'/v2/devices/api/set/switch' => $this->handleSetSwitch($department_id, $data),
'/v2/devices/api/batch/get' => $this->handleBatchGetStates($department_id, $data),
'/v2/devices/api/batch/set/switch' => $this->handleBatchSetSwitch($department_id, $data),
default => throw new Exception('Unsupported gateway Shelly transport endpoint: ' . $endpoint),
};
}
@@ -95,6 +97,52 @@ class gateway_shelly_transport implements shelly_transport_i
return [$this->normalizeRelayPayload($logicalRelayId, $status)];
}
/**
* @throws Exception
*/
private function handleBatchGetStates(int $departmentId, array $data): array
{
$requests = [];
foreach ((array)($data['commands'] ?? $data['targets'] ?? []) as $entry) {
$command = is_array($entry) ? $entry : ['relay_id' => $entry];
$relayId = trim((string)($command['relay_id'] ?? $command['relayId'] ?? $command['id'] ?? ''));
if ($relayId === '') {
continue;
}
$requests[] = [
'target' => strtoupper(trim((string)($command['target'] ?? $relayId))),
'relay_id' => $relayId,
];
}
return $this->manager()->queueRelayStatusBatch($departmentId, $requests, null, $this->localOnly);
}
/**
* @throws Exception
*/
private function handleBatchSetSwitch(int $departmentId, array $data): array
{
$requests = [];
foreach ((array)($data['commands'] ?? []) as $entry) {
if (!is_array($entry)) {
continue;
}
$relayId = trim((string)($entry['relay_id'] ?? $entry['relayId'] ?? $entry['id'] ?? ''));
if ($relayId === '') {
continue;
}
$requests[] = [
'target' => strtoupper(trim((string)($entry['target'] ?? $relayId))),
'relay_id' => $relayId,
'on' => (bool)($entry['on'] ?? false),
'toggle_after' => $entry['toggle_after'] ?? $entry['toggleAfter'] ?? $entry['timer'] ?? null,
];
}
return $this->manager()->queueRelaySwitchBatch($departmentId, $requests, null, $this->localOnly);
}
/**
* @param array<string,mixed> $status
* @return array<string,mixed>
@@ -30,6 +30,7 @@ class invoice_period_flag_service
public function __construct()
{
invoice_period_flag_schema_bootstrap::ensureTables();
price_overrides_schema_bootstrap::ensureColumns();
}
public function createManualFlag(array $payload, int $userId): array
@@ -688,6 +689,7 @@ class invoice_period_flag_service
o.po AS order_po,
o.notes AS order_notes,
o.department_id,
d.custom_pricing_only AS department_custom_pricing_only,
o.reg_1,
o.invoice_collection_id,
o.wash_id,
@@ -711,6 +713,7 @@ class invoice_period_flag_service
c.name AS category_name,
pdp.price AS department_price,
product_discount.percentage AS product_discount_percentage,
product_discount.fixed_price AS product_fixed_price,
category_discount.percentage AS category_discount_percentage
FROM orders o
LEFT JOIN (
@@ -720,11 +723,12 @@ class invoice_period_flag_service
GROUP BY customer_number
) u ON u.customer_number = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
LEFT JOIN departments d ON d.id = o.department_id
LEFT JOIN products p ON p.id = oi.product_id
LEFT JOIN categories c ON c.id = p.category
LEFT JOIN product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id
LEFT JOIN (
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage, MAX(po.fixed_price) AS fixed_price
FROM price_overrides po
INNER JOIN users discount_user ON discount_user.id = po.user_id
WHERE po.is_category = 0
@@ -1921,7 +1925,19 @@ class invoice_period_flag_service
private function calculateExpectedPrice(array $row): int
{
$base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0);
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
if ($customMissingPrice) {
return \objects\products_o::CUSTOM_PRICING_MISSING_PRICE;
}
$fixedPrice = $this->rowProductFixedPrice($row);
if ($fixedPrice !== null) {
return $fixedPrice;
}
$base = $row['department_price'] !== null
? (int)$row['department_price']
: (int)($row['product_base_price'] ?? 0);
$discount = $this->discountBreakdown($row)['applied_discount_percentage'];
return (int)round($base * (1 - ($discount / 100)));
}
@@ -1929,13 +1945,18 @@ class invoice_period_flag_service
private function priceBreakdown(array $row, int $expected): array
{
$departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null;
$base = $departmentPrice ?? (int)($row['product_base_price'] ?? 0);
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
$base = $departmentPrice ?? ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0));
$discount = $this->discountBreakdown($row);
if ($customMissingPrice) {
$discount['applied_discount_percentage'] = 0;
}
return [
'product_price' => (int)($row['product_base_price'] ?? 0),
'product_price' => $customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0),
'department_price' => $departmentPrice,
'effective_base_price' => $base,
'product_fixed_price' => $this->rowProductFixedPrice($row),
'product_discount_percentage' => $discount['product_discount_percentage'],
'category_discount_percentage' => $discount['category_discount_percentage'],
'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'],
@@ -1944,21 +1965,36 @@ class invoice_period_flag_service
];
}
private function isCustomMissingDepartmentPrice(array $row): bool
{
return $row['department_price'] === null && (bool)(int)($row['department_custom_pricing_only'] ?? 0);
}
private function discountBreakdown(array $row): array
{
$productDiscount = (int)($row['product_discount_percentage'] ?? 0);
$categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1;
$categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0;
$economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0;
$appliedDiscount = $this->rowProductFixedPrice($row) !== null
? 0
: max($productDiscount, $categoryDiscount, $economicDiscount);
return [
'product_discount_percentage' => $productDiscount,
'category_discount_percentage' => $categoryDiscount,
'economic_customer_discount_percentage' => $economicDiscount,
'applied_discount_percentage' => max($productDiscount, $categoryDiscount, $economicDiscount),
'applied_discount_percentage' => $appliedDiscount,
];
}
private function rowProductFixedPrice(array $row): ?int
{
return array_key_exists('product_fixed_price', $row) && $row['product_fixed_price'] !== null
? (int)$row['product_fixed_price']
: null;
}
private function economicCustomerDiscountPercentage(array $row): int
{
$customerNumber = (int)($row['customer_number'] ?? 0);
@@ -2036,8 +2072,7 @@ class invoice_period_flag_service
private function rowIsTankCleaningProduct(array $row): bool
{
return (int)($row['product_category'] ?? 0) === 5
|| $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
return customer_order_product_policy::isTankCleaningProductRow($row);
}
private function isIncludedOrderItem(array $row): bool
@@ -0,0 +1,24 @@
<?php
namespace classes;
class limited_backoffice_exception extends \RuntimeException
{
public function __construct(
string $message,
private readonly int $statusCode = 400,
private readonly ?array $payload = null
) {
parent::__construct($message);
}
public function statusCode(): int
{
return $this->statusCode;
}
public function payload(): array|string
{
return $this->payload ?? $this->getMessage();
}
}
@@ -0,0 +1,43 @@
<?php
namespace classes;
class limited_backoffice_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `limited_backoffice_employees` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`managed_group_id` INT NOT NULL,
`role_key` VARCHAR(64) NOT NULL,
`department_ids` LONGTEXT NOT NULL,
`created_by_user_id` INT NOT NULL,
`updated_by_user_id` INT NULL,
`deactivated_at` DATETIME NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_limited_backoffice_employees_user_id` (`user_id`),
KEY `idx_limited_backoffice_employees_group_id` (`managed_group_id`),
KEY `idx_limited_backoffice_employees_role_key` (`role_key`),
KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
self::$initialized = true;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,68 @@
<?php
namespace classes;
/**
* Ensures additive schema for customer product price overrides.
*/
class price_overrides_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureColumns(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
if (!self::tableExists($db, 'price_overrides')) {
return;
}
if (!self::columnExists($db, 'price_overrides', 'fixed_price')) {
$db->query(
"ALTER TABLE price_overrides
ADD COLUMN fixed_price INT NULL DEFAULT NULL
AFTER percentage"
);
}
self::$initialized = true;
}
private static function tableExists(object $db, string $table): bool
{
$table = self::escapeIdentifier($table);
$result = $db->query("SHOW TABLES LIKE '{$table}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
return (int)$result->num_rows > 0;
}
private static function columnExists(object $db, string $table, string $column): bool
{
$table = self::escapeIdentifier($table);
$column = self::escapeIdentifier($column);
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
return (int)$result->num_rows > 0;
}
private static function escapeIdentifier(string $value): string
{
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ class router
}
// Regex
$route = str_replace('/', '\/', $route);
$route = preg_replace('/{[a-zA-Z0-9]+}/', '([a-zA-Z0-9]+)', $route);
$route = preg_replace('/{[a-zA-Z0-9_]+}/', '([a-zA-Z0-9]+)', $route);
if (preg_match('/^' . $route . '$/', $this->url)) {
return true;
}
+99
View File
@@ -196,11 +196,104 @@ class slack implements notification_i
];
}
/**
* Send a sanitized internal department goal progress test notification to the saved Slack webhook.
*
* @return array{configured:bool,sent:bool,message:string}
*/
public function test_internal_department_goal_progress_webhook(): array
{
$webhook = $this->get_internal_department_goal_progress_webhook_url();
if ($webhook === '') {
return [
'configured' => false,
'sent' => false,
'message' => 'Slack internal department goal progress webhook URL is not configured.',
];
}
$result = $this->send_webhook_message(
$this->format_internal_department_goal_progress_test(),
$webhook
);
$sent = $this->is_webhook_send_successful($result);
self::add_log($sent
? 'Slack internal department goal progress test webhook sent successfully.'
: 'Slack internal department goal progress test webhook failed.'
);
return [
'configured' => true,
'sent' => $sent,
'message' => $sent
? 'Slack test message sent successfully.'
: 'Slack test message failed.',
];
}
protected function get_customer_registration_webhook_url(): string
{
return trim((string)$this->getConfig()->customer_registration_webhook_url->getVariableValue());
}
public function get_internal_department_goal_progress_webhook_url(): string
{
return trim((string)$this->getConfig()->internal_department_goal_progress_webhook_url->getVariableValue());
}
/**
* @return int[]
*/
public function get_internal_department_ids(): array
{
return $this->getConfig()->internal_department_ids->getDepartmentIds();
}
/**
* @param int[] $department_ids
* @throws \Exception
*/
public function set_internal_department_goal_progress_config(string $webhook_url, array $department_ids): array
{
$this->getConfig()->internal_department_goal_progress_webhook_url->setVariableValue(trim($webhook_url));
$this->getConfig()->internal_department_ids->setVariableValue($department_ids);
return $this->get_internal_department_goal_progress_config();
}
public function get_internal_department_goal_progress_config(): array
{
$departments = (new departments_o())->getFieldsWhere(
[
'visible' => 1,
'archived' => 0,
],
[
'id',
'name',
'order_priority',
]
);
usort($departments, static function (array $a, array $b): int {
return (int)($a['order_priority'] ?? 0) <=> (int)($b['order_priority'] ?? 0)
?: (int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0);
});
return [
'internal_department_goal_progress_webhook_url' => $this->get_internal_department_goal_progress_webhook_url(),
'internal_department_ids' => $this->get_internal_department_ids(),
'departments' => array_map(static function (array $department): array {
return [
'id' => (int)$department['id'],
'name' => (string)$department['name'],
'order_priority' => (int)$department['order_priority'],
];
}, $departments),
];
}
public function is_webhook_send_successful(string $result): bool
{
return !str_starts_with($result, 'Failed to send message:');
@@ -230,4 +323,10 @@ class slack implements notification_i
return "*Truck Wash Slack test*\n"
. "Customer registration notifications are configured correctly.";
}
public function format_internal_department_goal_progress_test(): string
{
return "*Truck Wash Slack test*\n"
. "Internal department goal progress notifications are configured correctly.";
}
}
@@ -499,6 +499,7 @@ class system_search_document_index
*/
private function buildCustomerDiscountDocuments(): array
{
price_overrides_schema_bootstrap::ensureColumns();
$fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id';
$selectFields = [
'po.id AS entity_id',
@@ -506,6 +507,7 @@ class system_search_document_index
'po.is_category',
'po.product_or_category_id',
'po.percentage',
'po.fixed_price',
'u.customer_number',
'u.display_name',
...$this->joinTemporalSelectFields('price_overrides', 'po'),
@@ -554,6 +556,7 @@ class system_search_document_index
$row['search_text'] ?? null,
$row['product_or_category_id'] ?? null,
$row['percentage'] ?? null,
$row['fixed_price'] ?? null,
$row['user_id'] ?? null,
]),
$this->toIntOrNull($row['customer_number'] ?? null),
@@ -564,6 +567,7 @@ class system_search_document_index
'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
'product_or_category_id' => $row['product_or_category_id'] ?? null,
'percentage' => $this->toIntOrNull($row['percentage'] ?? null),
'fixed_price' => $this->toIntOrNull($row['fixed_price'] ?? null),
'economic_name' => $row['economic_name'] ?? null,
'economic_cvr' => $row['economic_cvr'] ?? null,
'is_category' => $row['is_category'] ?? null,
@@ -1086,6 +1086,7 @@ class system_search_service
private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
{
price_overrides_schema_bootstrap::ensureColumns();
$customerFilter = '';
if (!empty($forcedCustomerNumbers)) {
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')';
@@ -1100,11 +1101,12 @@ class system_search_service
'po.is_category',
'po.product_or_category_id',
'po.percentage',
'po.fixed_price',
'u.customer_number',
'u.display_name',
...$this->joinTemporalSelectFields('price_overrides', 'po'),
];
$searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'u.customer_number', 'u.display_name'];
$searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'po.fixed_price', 'u.customer_number', 'u.display_name'];
if ($this->isEconomicCustomerIndexAvailable()) {
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
@@ -1166,6 +1168,7 @@ class system_search_service
'search_text',
'product_or_category_id',
'percentage',
'fixed_price',
'user_id',
], $terms) + $entityBoost,
'payload' => $this->augmentPayloadWithTemporal([
@@ -1173,6 +1176,7 @@ class system_search_service
'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
'product_or_category_id' => $row['product_or_category_id'] ?? null,
'percentage' => isset($row['percentage']) ? (int)$row['percentage'] : null,
'fixed_price' => isset($row['fixed_price']) ? (int)$row['fixed_price'] : null,
'economic_name' => $row['economic_name'] ?? null,
'economic_cvr' => $row['economic_cvr'] ?? null,
], $row),
@@ -1246,19 +1246,20 @@ class xlvask_automation_service
{
global $db;
(new xlvask_usage_logs_o())->structure();
$startTimeExpression = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')";
$where = [
'FinishStatus = 1',
'(ignored_at IS NULL OR ignored_at = "")',
];
if ($dateFrom !== null && strtotime($dateFrom) !== false) {
$where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
$where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
} else {
$where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
$where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
}
if ($dateTo !== null && strtotime($dateTo) !== false) {
$where[] = "StartTime <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
$where[] = "{$startTimeExpression} <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
}
$limit = max(1, min(500, $limit));
@@ -14,6 +14,8 @@ class economic_customer_mo
public null|string $message;
public null|string $corporateIdentificationNumber;
public null|string $email;
public null|string $ean;
public null|string $publicEntryNumber;
public null|string $mobilePhone;
public null|string $currency;
public null|string $country;
@@ -46,6 +48,8 @@ class economic_customer_mo
$this->zip = ($customer->zip ?? null);
$this->corporateIdentificationNumber = ($customer->corporateIdentificationNumber ?? null);
$this->email = ($customer->email ?? null);
$this->ean = ($customer->ean ?? null);
$this->publicEntryNumber = ($customer->publicEntryNumber ?? $customer->public_entry_number ?? null);
$this->mobilePhone = ($customer->mobilePhone ?? null);
$this->currency = ($customer->currency ?? null);
$this->country = ($customer->country ?? null);
@@ -100,6 +104,8 @@ class economic_customer_mo
'zip' => $this->zip,
'corporateIdentificationNumber' => $this->corporateIdentificationNumber,
'email' => $this->email,
'ean' => $this->ean,
'publicEntryNumber' => $this->publicEntryNumber,
'mobilePhone' => $this->mobilePhone,
'currency' => $this->currency,
'country' => $this->country,
@@ -61,19 +61,47 @@ class economic_invoices_draft_endpoint
* @throws Exception If the request fails
*/
public function add_order(int $invoiceDraftId, orders_o $order, string $currency = 'DKK'): void
{
$this->add_orders($invoiceDraftId, [$order], $currency);
}
/**
* Add many orders to a draft invoice and flush their lines in batches.
*
* @param orders_o[] $orders
* @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array<int,int>}
* @throws Exception If the request fails
*/
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500): array
{
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
// Check if the order includes any items that should be included in the invoice
if ($order->getIncludeInInvoiceCount() > 0) {
$orders_with_invoice_lines = 0;
foreach ( $orders as $order ) {
if (!$order instanceof orders_o) {
throw new Exception('Order payload must contain orders_o instances');
}
// Check if the order includes any items that should be included in the invoice
if ($order->getIncludeInInvoiceCount() <= 0) {
continue;
}
$orders_with_invoice_lines++;
// Add the transaction header (Timestamp, department, etc.)
$draftInvoice->addNewTransactionHeader($order);
// Add the order lines
$draftInvoice->addOrderItemLines($order);
// Add an empty line, so the invoice is not empty
$draftInvoice->addTextLine('');
// Save the draft invoice lines
$draftInvoice->addLines();
}
$metrics = $draftInvoice->flushLinesInBatches($line_batch_size);
return [
'order_count' => count($orders),
'orders_with_invoice_lines' => $orders_with_invoice_lines,
...$metrics,
];
}
/**
@@ -151,4 +179,4 @@ class economic_invoices_draft_endpoint
$draft_invoice->addLines();
}
}
}
}
@@ -127,6 +127,23 @@ class economic_invoices_drafts_endpoint
$customer_address = $customer->getAddress() ?? 'Ukendt';
$customer_zip = $customer->getZipCode() ?? 'Ukendt';
$customer_city = $customer->getCity() ?? 'Ukendt';
$recipient = [
'name' => $customer_name,
'address' => $customer_address,
'zip' => $customer_zip,
'city' => $customer_city,
'vatZone' => [
'vatZoneNumber' => (int)$customer->getVatZoneNumber(),
],
];
$customer_ean = $customer->getEan();
if ($customer_ean !== null) {
$recipient['ean'] = $customer_ean;
}
$public_entry_number = $customer->getPublicEntryNumber();
if ($public_entry_number !== null) {
$recipient['publicEntryNumber'] = $public_entry_number;
}
// Send the request
$response = $this->send_request(
@@ -165,15 +182,7 @@ class economic_invoices_drafts_endpoint
'currency' => $customer->getCurrency() ?? 'DKK',
// Set the recipient details
'recipient' => [
'name' => $customer_name,
'address' => $customer_address,
'zip' => $customer_zip,
'city' => $customer_city,
'vatZone' => [
'vatZoneNumber' => (int)$customer->getVatZoneNumber(),
],
],
'recipient' => $recipient,
])
);
// Return the response as an object
@@ -194,4 +203,4 @@ class economic_invoices_drafts_endpoint
}
}
}
}
@@ -149,6 +149,29 @@ class economic_customer
return $this->customer_data_object->email;
}
public function getEan(): ?string
{
self::requireSelected();
return $this->nullableStringField('ean');
}
public function getPublicEntryNumber(): ?string
{
self::requireSelected();
return $this->nullableStringField('publicEntryNumber');
}
protected function nullableStringField(string $field): ?string
{
$value = $this->customer_data_object->{$field} ?? null;
if ($value === null) {
return null;
}
$normalized = trim((string)$value);
return $normalized !== '' ? $normalized : null;
}
/**
* Get the customer address
* @return string The customer address
@@ -227,4 +250,4 @@ class economic_customer
return $this->customer_data_object->vatZone->vatZoneNumber;
}
}
}
@@ -10,6 +10,8 @@ use objects\orders_o;
class economic_invoice_draft
{
public const DEFAULT_LINE_BATCH_SIZE = 500;
/**
* The Economic draftInvoiceNumber
* @var int $draft_invoice_number
@@ -110,13 +112,55 @@ class economic_invoice_draft
}
/**
* Add the lines to the draft invoice
* @return void
* Add the lines to the draft invoice.
*/
public function addLines(): void
{
$this->flushLinesInBatches();
}
/**
* Add queued draft lines using chunked requests.
*
* @return array{line_count:int,batch_count:int,batch_sizes:array<int,int>}
*/
public function flushLinesInBatches(int $batch_size = self::DEFAULT_LINE_BATCH_SIZE): array
{
$lines = array_values($this->draft_lines);
$line_count = count($lines);
if ($line_count === 0) {
return [
'line_count' => 0,
'batch_count' => 0,
'batch_sizes' => [],
];
}
$batch_size = max(1, $batch_size);
$batch_sizes = [];
foreach (array_chunk($lines, $batch_size) as $batch) {
$this->sendDraftLines($batch);
$batch_sizes[] = count($batch);
}
$this->draft_lines = [];
return [
'line_count' => $line_count,
'batch_count' => count($batch_sizes),
'batch_sizes' => $batch_sizes,
];
}
public function pendingLineCount(): int
{
return count($this->draft_lines);
}
protected function sendDraftLines(array $draft_lines): object
{
$economic = new economic();
$economic->invoices->draft->add_lines($this->draft_invoice_number, $this->draft_lines);
return $economic->invoices->draft->add_lines($this->draft_invoice_number, $draft_lines);
}
/**
@@ -6,6 +6,7 @@ use Exception;
class edge_gateway_agent_artifact_locator
{
private const EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY = 'build/install';
private const ROUTER_ARTIFACT_DIRECTORY = 'resources/edge-gateway-agent';
private const DEFAULT_MOUNTED_ARTIFACT_DIRECTORY = '/services/edge-agent/php-agent';
private const DEFAULT_BAKED_ARTIFACT_DIRECTORY = '/opt/truckwash-edge-agent-artifacts';
@@ -30,6 +31,10 @@ class edge_gateway_agent_artifact_locator
$candidateDirectories[] = self::routerArtifactDirectory($basePath);
foreach (self::edgeAgentBuildDirectories($basePath) as $directory) {
$candidateDirectories[] = $directory;
}
$mountedArtifactDirectory = $mountedArtifactDirectory ?? self::mountedArtifactDirectory();
if ($mountedArtifactDirectory !== null) {
$candidateDirectories[] = self::normalizePath($mountedArtifactDirectory);
@@ -40,9 +45,9 @@ class edge_gateway_agent_artifact_locator
$candidateDirectories[] = self::normalizePath($bakedArtifactDirectory);
}
$candidateDirectories[] = self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
$candidateDirectories[] = self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
$candidateDirectories[] = self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
foreach (self::legacyPhpAgentDirectories($basePath) as $directory) {
$candidateDirectories[] = $directory;
}
$paths = [];
foreach (array_values(array_unique($candidateDirectories)) as $directory) {
@@ -102,6 +107,42 @@ class edge_gateway_agent_artifact_locator
return self::normalizePath($basePath . DIRECTORY_SEPARATOR . self::ROUTER_ARTIFACT_DIRECTORY);
}
/**
* @return array<int,string>
*/
private static function edgeAgentBuildDirectories(string $basePath): array
{
$relative = str_replace('/', DIRECTORY_SEPARATOR, self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY);
$directories = [
dirname($basePath, 4) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
];
if (DIRECTORY_SEPARATOR === '/') {
array_unshift($directories, '/edge-agent/' . self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY);
$directories[] = '/services/edge-agent/' . self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY;
}
return array_values(array_unique(array_map(
static fn(string $directory): string => self::normalizePath($directory),
$directories
)));
}
/**
* @return array<int,string>
*/
private static function legacyPhpAgentDirectories(string $basePath): array
{
return [
self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
];
}
private static function mountedArtifactDirectory(): ?string
{
if (DIRECTORY_SEPARATOR !== '/') {
@@ -7,6 +7,7 @@ use Exception;
class edge_gateway_install_service
{
private const ARTIFACTS = [
'manifest.json' => 'application/json; charset=utf-8',
'agent.php' => 'application/x-httpd-php; charset=utf-8',
'lan-worker.php' => 'application/x-httpd-php; charset=utf-8',
'auto-updater.php' => 'application/x-httpd-php; charset=utf-8',
@@ -26,7 +27,7 @@ class edge_gateway_install_service
public function buildInstallScript(string $plainToken): string
{
return $this->manager()->buildInstallScript($plainToken);
return $this->normalizeLineEndings($this->manager()->buildInstallScript($plainToken));
}
/**
@@ -42,13 +43,17 @@ class edge_gateway_install_service
*/
public function readArtifact(string $fileName): string
{
if ($fileName === 'manifest.json') {
return $this->buildManifest();
}
$path = $this->artifactPath($fileName);
$contents = file_get_contents($path);
if ($contents === false) {
throw new Exception('Unable to read edge agent artifact');
}
return $contents;
return $this->normalizeLineEndings($contents);
}
public function contentType(string $fileName): string
@@ -66,15 +71,55 @@ class edge_gateway_install_service
*/
public function artifactPath(string $fileName): string
{
if (!array_key_exists($fileName, self::ARTIFACTS)) {
if (!array_key_exists($fileName, self::ARTIFACTS) || $fileName === 'manifest.json') {
throw new Exception('Unknown edge agent artifact');
}
return edge_gateway_agent_artifact_locator::resolve($fileName);
}
/**
* @throws Exception
*/
private function buildManifest(): string
{
$artifacts = [];
foreach (self::ARTIFACTS as $fileName => $contentType) {
if ($fileName === 'manifest.json') {
continue;
}
$path = $this->artifactPath($fileName);
$sha256 = hash_file('sha256', $path);
$bytes = filesize($path);
if ($sha256 === false || $bytes === false) {
throw new Exception('Unable to inspect edge agent artifact: ' . $fileName);
}
$artifacts[] = [
'name' => $fileName,
'sha256' => $sha256,
'bytes' => $bytes,
'content_type' => $contentType,
];
}
return json_encode([
'schema_version' => 1,
'package' => 'truckwash-edge-agent',
'version' => edge_gateway_manager::DEFAULT_INSTALL_VERSION,
'generated_at' => gmdate('c'),
'artifacts' => $artifacts,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
}
private function manager(): edge_gateway_manager
{
return $this->manager ?? new edge_gateway_manager();
}
private function normalizeLineEndings(string $contents): string
{
return str_replace(["\r\n", "\r"], "\n", $contents);
}
}
File diff suppressed because it is too large Load Diff
@@ -18,8 +18,7 @@ class edge_gateway_schema_bootstrap
return;
}
global $db;
$pdo = self::pdo();
$queries = [
"CREATE TABLE IF NOT EXISTS edge_gateways (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -124,6 +123,31 @@ class edge_gateway_schema_bootstrap
INDEX idx_edge_gateway_command_type (command_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS edge_gateway_expected_relay_states (
id INT AUTO_INCREMENT PRIMARY KEY,
gateway_id INT NOT NULL,
department_id INT NOT NULL,
lane_id INT NOT NULL,
role VARCHAR(32) NOT NULL,
relay_id VARCHAR(255) NOT NULL,
device_id VARCHAR(255) NULL,
local_ip VARCHAR(64) NULL,
device_type VARCHAR(64) NOT NULL DEFAULT 'UNKNOWN',
expected_state TINYINT(1) NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL,
completed_at DATETIME NULL,
last_attempted_at DATETIME NULL,
last_error TEXT NULL,
metadata_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
row_updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
UNIQUE KEY uniq_edge_gateway_expected_relay_state (gateway_id, lane_id, role),
INDEX idx_edge_gateway_expected_relay_gateway (gateway_id),
INDEX idx_edge_gateway_expected_relay_department (department_id),
INDEX idx_edge_gateway_expected_relay_pending (gateway_id, completed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS edge_gateway_operations (
id INT AUTO_INCREMENT PRIMARY KEY,
gateway_id INT NOT NULL,
@@ -233,11 +257,18 @@ class edge_gateway_schema_bootstrap
];
foreach ($queries as $sql) {
$db->query($sql);
$pdo->exec($sql);
}
self::ensureColumn('edge_gateway_command_jobs', 'delivery_json', 'JSON NULL AFTER response_json');
self::ensureColumn('edge_gateway_expected_relay_states', 'device_type', "VARCHAR(64) NOT NULL DEFAULT 'UNKNOWN' AFTER local_ip");
self::ensureColumn('edge_gateway_expected_relay_states', 'last_attempted_at', 'DATETIME NULL AFTER completed_at');
self::ensureColumn('edge_gateway_expected_relay_states', 'last_error', 'TEXT NULL AFTER last_attempted_at');
self::ensureColumn('edge_gateway_expected_relay_states', 'metadata_json', 'JSON NULL AFTER last_error');
self::ensureColumn('edge_gateway_expected_relay_states', 'row_updated_at', 'TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
self::ensureColumn('edge_gateway_expected_relay_states', 'deleted_at', 'TIMESTAMP NULL DEFAULT NULL AFTER row_updated_at');
self::ensureColumn('edge_gateway_relay_bindings', 'fallback_mode', "VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL' AFTER channel");
self::ensureColumn('edge_gateway_operations', 'type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id");
@@ -304,8 +335,6 @@ class edge_gateway_schema_bootstrap
private static function ensureColumn(string $table, string $column, string $definition): void
{
global $db;
if (self::tableHasColumn($table, $column)) {
return;
}
@@ -314,7 +343,7 @@ class edge_gateway_schema_bootstrap
throw new \RuntimeException('Invalid schema bootstrap identifier');
}
$db->query(
self::pdo()->exec(
"ALTER TABLE `$table`
ADD COLUMN `$column` $definition"
);
@@ -327,8 +356,6 @@ class edge_gateway_schema_bootstrap
string $definition,
?string $afterColumn = null
): void {
global $db;
if (!self::tableHasColumn($table, $from) || self::tableHasColumn($table, $to)) {
return;
}
@@ -342,7 +369,7 @@ class edge_gateway_schema_bootstrap
$positionClause = $afterColumn === null ? '' : " AFTER `$afterColumn`";
$db->query(
self::pdo()->exec(
"ALTER TABLE `$table`
CHANGE COLUMN `$from` `$to` $definition$positionClause"
);
@@ -350,38 +377,35 @@ class edge_gateway_schema_bootstrap
private static function tableHasColumn(string $table, string $column): bool
{
global $db;
$table = $db->escape_string($table);
$column = $db->escape_string($column);
$database = $db->escape_string($db->getDatabase());
$result = $db->query(
"SELECT COUNT(*) AS c
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = '$database'
AND TABLE_NAME = '$table'
AND COLUMN_NAME = '$column'"
);
if (!$result) {
return false;
if (!preg_match('/^[A-Za-z0-9_]+$/', $table) || !preg_match('/^[A-Za-z0-9_]+$/', $column)) {
throw new \RuntimeException('Invalid schema bootstrap identifier');
}
$row = $result->fetch_assoc();
$statement = self::pdo()->prepare(
"SELECT COUNT(*) AS c
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table
AND COLUMN_NAME = :column"
);
$statement->execute([
':table' => $table,
':column' => $column,
]);
$row = $statement->fetch();
return ((int)($row['c'] ?? 0)) > 0;
}
private static function syncOperationTypeColumns(): void
{
global $db;
if (!self::tableHasColumn('edge_gateway_operations', 'type')
|| !self::tableHasColumn('edge_gateway_operations', 'operation_type')) {
return;
}
$db->query(
$pdo = self::pdo();
$pdo->exec(
"UPDATE edge_gateway_operations
SET type = operation_type
WHERE operation_type IS NOT NULL
@@ -389,7 +413,7 @@ class edge_gateway_schema_bootstrap
AND (type IS NULL OR type = '' OR type <> operation_type)"
);
$db->query(
$pdo->exec(
"UPDATE edge_gateway_operations
SET operation_type = type
WHERE type IS NOT NULL
@@ -397,4 +421,88 @@ class edge_gateway_schema_bootstrap
AND (operation_type IS NULL OR operation_type = '')"
);
}
private static function pdo(): \PDO
{
$envConfig = self::databaseConfigFromEnvironment();
if ($envConfig !== null) {
return self::connectPdo($envConfig);
}
if (!class_exists(db::class, false)) {
$dbClassPath = __DIR__ . '/../../../classes/db.php';
if (is_file($dbClassPath)) {
require_once $dbClassPath;
}
}
if (!class_exists(db::class)) {
throw new \RuntimeException('Database connection helper is not available.');
}
return db::getPDO();
}
/**
* @return array{host:string,user:string,password:string,database:string,port:int}|null
*/
private static function databaseConfigFromEnvironment(): ?array
{
$target = strtolower(self::envString('CONFIG_DB_TARGET') ?: 'live');
if ($target !== 'debug') {
$target = 'live';
}
$host = self::databaseEnvValue('HOST', $target);
$user = self::databaseEnvValue('USER', $target);
$database = self::databaseEnvValue('DATABASE', $target);
if ($host === '' || $user === '' || $database === '') {
return null;
}
return [
'host' => $host,
'user' => $user,
'password' => self::databaseEnvValue('PASSWORD', $target),
'database' => $database,
'port' => (int)(self::databaseEnvValue('PORT', $target) ?: 3306),
];
}
private static function databaseEnvValue(string $key, string $target): string
{
$liveValue = self::envString('CONFIG_DB_' . $key);
$debugValue = self::envString('CONFIG_DB_DEBUG_' . $key);
if ($target === 'debug' && $debugValue !== '') {
return $debugValue;
}
return $liveValue;
}
private static function envString(string $key): string
{
$value = getenv($key);
if ($value === false || $value === null) {
$value = $_ENV[$key] ?? $_SERVER[$key] ?? '';
}
return trim((string)$value);
}
/**
* @param array{host:string,user:string,password:string,database:string,port:int} $config
*/
private static function connectPdo(array $config): \PDO
{
$port = $config['port'] > 0 ? $config['port'] : 3306;
$dsn = "mysql:host={$config['host']};port={$port};dbname={$config['database']};charset=utf8mb4";
return new \PDO($dsn, $config['user'], $config['password'], [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_EMULATE_PREPARES => false,
]);
}
}
@@ -81,6 +81,7 @@ class edgeGatewaysRoute
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
$this->post('/edge-agent/install-token/status', fn() => $this->handleAgentInstallTokenStatus());
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
$this->get('/edge-agent/artifacts/manifest.json', fn() => $this->renderArtifact('manifest.json'));
$this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php'));
$this->get('/edge-agent/artifacts/lan-worker.php', fn() => $this->renderArtifact('lan-worker.php'));
$this->get('/edge-agent/artifacts/auto-updater.php', fn() => $this->renderArtifact('auto-updater.php'));
@@ -98,6 +99,8 @@ class edgeGatewaysRoute
$this->post('/edge-agent/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleAgentOperationComplete());
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
$this->post('/edge-agent/gateways/{id}/expected-relay-states', fn() => $this->handleAgentExpectedRelayStates());
$this->post('/edge-agent/gateways/{id}/relay-state-results', fn() => $this->handleAgentRelayStateResults());
$this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence());
$this->post('/edge-agent/gateways/{id}/selfserve/machine-signal-bindings', fn() => $this->handleAgentSelfserveMachineSignalBindings());
$this->post('/edge-agent/gateways/{id}/selfserve/machine-signal', fn() => $this->handleAgentSelfserveMachineSignal());
@@ -109,6 +112,7 @@ class edgeGatewaysRoute
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/events', fn() => $this->handleBrokerOperationEvent());
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleBrokerOperationComplete());
$this->post('/edge-agent/internal/gateways/{id}/logs', fn() => $this->handleBrokerGatewayLogEntry());
$this->post('/edge-agent/internal/gateways/{id}/selfserve/machine-signal', fn() => $this->handleBrokerSelfserveMachineSignal());
$this->post('/edge-agent/internal/browser-streams/validate', fn() => $this->handleBrokerBrowserStreamValidate());
$this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellSessionValidate());
$this->post('/edge-agent/internal/shell-sessions/opened', fn() => $this->handleBrokerShellSessionOpened());
@@ -398,7 +402,11 @@ class edgeGatewaysRoute
$response->error('Missing token', 400);
}
$response->success($this->install()->verifyInstallToken($token));
try {
$response->success($this->install()->verifyInstallToken($token));
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
}
private function handleAgentInstallTokenStatus(): void
@@ -407,17 +415,21 @@ class edgeGatewaysRoute
self::requireParameters(['token', 'status']);
$payload = self::getParametersAsArray();
$response->success($this->registry()->reportInstallTokenStatus(
(string)$payload['token'],
[
'status' => (string)$payload['status'],
'step' => isset($payload['step']) ? (string)$payload['step'] : null,
'message' => isset($payload['message']) ? (string)$payload['message'] : null,
'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [],
'gateway_id' => isset($payload['gateway_id']) ? (int)$payload['gateway_id'] : null,
'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : null,
]
));
try {
$response->success($this->registry()->reportInstallTokenStatus(
(string)$payload['token'],
[
'status' => (string)$payload['status'],
'step' => isset($payload['step']) ? (string)$payload['step'] : null,
'message' => isset($payload['message']) ? (string)$payload['message'] : null,
'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [],
'gateway_id' => isset($payload['gateway_id']) ? (int)$payload['gateway_id'] : null,
'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : null,
]
));
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
}
private function renderArtifact(string $fileName): void
@@ -439,15 +451,19 @@ class edgeGatewaysRoute
self::requireParameters(['token']);
$payload = self::getParametersAsArray();
$response->success(
$this->registry()->claimGateway(
(string)$payload['token'],
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
),
201
);
try {
$response->success(
$this->registry()->claimGateway(
(string)$payload['token'],
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
),
201
);
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
}
private function handleAgentHeartbeat(): void
@@ -555,6 +571,31 @@ class edgeGatewaysRoute
));
}
private function handleAgentExpectedRelayStates(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$payload = self::getParametersAsArray();
$response->success($this->manager()->buildExpectedRelayStatesForAgent(
$gatewayId,
$this->requireAgentToken($payload),
isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : 0
));
}
private function handleAgentRelayStateResults(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$payload = self::getParametersAsArray();
$results = isset($payload['results']) && is_array($payload['results']) ? (array)$payload['results'] : [];
$response->success($this->manager()->recordExpectedRelayStateResults(
$gatewayId,
$this->requireAgentToken($payload),
$results
));
}
private function handleAgentPresence(): void
{
global /** @var response $response */ $response;
@@ -706,6 +747,21 @@ class edgeGatewaysRoute
));
}
private function handleBrokerSelfserveMachineSignal(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSecret();
$gatewayId = (int)$this->fromRoute('id');
$payload = self::getParametersAsArray();
try {
$result = (new selfserve_machine_signal())->recordBrokerEdgeGatewaySignal($gatewayId, $payload);
$response->success($result, !empty($result['recorded']) ? 201 : 202);
} catch (\Throwable $exception) {
$response->error($exception->getMessage(), 400);
}
}
private function handleBrokerBrowserStreamValidate(): void
{
global /** @var response $response */ $response;
@@ -148,6 +148,25 @@ class selfserve_machine_signal
$gateway = (new edge_gateway_manager())->authenticateGateway($gatewayId, $agentToken);
$departmentId = (int)$gateway->department_id->value();
return $this->recordEdgeGatewaySignalForDepartment($gatewayId, $departmentId, $payload);
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
public function recordBrokerEdgeGatewaySignal(int $gatewayId, array $payload): array
{
$gateway = (new edge_gateway_manager())->getGateway($gatewayId);
return $this->recordEdgeGatewaySignalForDepartment($gatewayId, (int)$gateway['department_id'], $payload);
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
private function recordEdgeGatewaySignalForDepartment(int $gatewayId, int $departmentId, array $payload): array
{
return $this->recordCloudShellySignal(
$departmentId,
isset($payload['lane_id']) ? (int)$payload['lane_id'] : null,
@@ -360,9 +360,13 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$allowedServices = $this->normalizeServiceNames(
is_array($metadata['allowed_services'] ?? null) ? (array)$metadata['allowed_services'] : []
);
$machineAvailable = array_key_exists('machine_available', $metadata)
$machineWashEnabled = $this->isMachineWashEnabled();
if (!$machineWashEnabled) {
$allowedServices = $this->withoutMachineService($allowedServices);
}
$machineAvailable = $machineWashEnabled && (array_key_exists('machine_available', $metadata)
? (bool)$metadata['machine_available']
: ($lane->exists() && !empty($lane->relay_machine_id->value()));
: ($lane->exists() && !empty($lane->relay_machine_id->value())));
$allVisibleQuestionsAnswered = array_key_exists('all_visible_questions_answered', $metadata)
? (bool)$metadata['all_visible_questions_answered']
: true;
@@ -408,6 +412,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'events' => $events,
'allowed_services' => $allowedServices,
'machine_available' => $machineAvailable,
'machine_wash_enabled' => $machineWashEnabled,
'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
'allowed' => (bool)$session->allowed->value(),
'config_version_id' => $metadata['config_version_id'] ?? null,
@@ -687,7 +692,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
}
}
$machineAvailable = !empty($lane->relay_machine_id->value());
$machineWashEnabled = $this->isMachineWashEnabled();
if (!$machineWashEnabled) {
$allowedServices = $this->withoutMachineService($allowedServices);
}
$machineAvailable = $machineWashEnabled && !empty($lane->relay_machine_id->value());
$allVisibleQuestionsAnswered = true;
foreach ($visibleQuestions as $question) {
if ($question['answer'] === null) {
@@ -726,8 +736,10 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'tasks' => $visibleTasks,
'allowed_services' => $allowedServices,
'machine_available' => $machineAvailable,
'machine_wash_enabled' => $machineWashEnabled,
'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
'allowed' => $machineAllowed,
'blocked_reason' => !$machineWashEnabled ? 'Machine wash is disabled globally.' : null,
'config_version_id' => $publishedConfigVersionId === null ? null : (int)$publishedConfigVersionId,
'config_source' => $configSource,
'evaluation_trace' => [
@@ -826,6 +838,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'tasks' => $snapshot['tasks'],
'allowed_services' => $snapshot['allowed_services'],
'machine_available' => $snapshot['machine_available'],
'machine_wash_enabled' => $snapshot['machine_wash_enabled'] ?? true,
'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'],
'allowed' => $snapshot['allowed'],
'blocked_reason' => $snapshot['blocked_reason'] ?? null,
@@ -3310,6 +3323,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return [
'allowed_services' => $snapshot['allowed_services'],
'machine_available' => (bool)$snapshot['machine_available'],
'machine_wash_enabled' => (bool)($snapshot['machine_wash_enabled'] ?? true),
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
'config_version_id' => $snapshot['config_version_id'] ?? null,
'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
@@ -3608,6 +3622,27 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
));
}
protected function isMachineWashEnabled(): bool
{
try {
return (new selfserve())->config->machine_wash_enabled->isTrue();
} catch (\Throwable) {
return true;
}
}
/**
* @param array<int,string> $services
* @return array<int,string>
*/
protected function withoutMachineService(array $services): array
{
return array_values(array_filter(
$this->normalizeServiceNames($services),
static fn(string $service): bool => $service !== selfserve_lane_services::MACHINE->name
));
}
protected function taskUsesMachineControls(array $task): bool
{
if (in_array(selfserve_lane_services::MACHINE->name, $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), true)) {
@@ -0,0 +1,29 @@
<?php
namespace modules\selfserve\config;
use Exception;
use traits\module_config_variable;
class selfserve_machine_wash_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'selfserve',
'machine_wash_enabled',
'bool',
true,
null,
'Whether machine wash is available in customer-facing self-serve flows',
'1',
false,
true
);
}
}
@@ -2,12 +2,14 @@
namespace modules\selfserve;
require_once WD . '/modules/selfserve/config/selfserve_enabled_c.php';
require_once WD . '/modules/selfserve/config/selfserve_machine_wash_enabled_c.php';
require_once WD . '/modules/selfserve/config/selfserve_minute_product_c.php';
require_once WD . '/modules/selfserve/config/selfserve_machine_wash_minutes_included_c.php';
require_once WD . '/modules/selfserve/config/selfserve_dynamic_image_size_c.php';
use modules\selfserve\config\selfserve_dynamic_image_size_c;
use modules\selfserve\config\selfserve_enabled_c;
use modules\selfserve\config\selfserve_machine_wash_enabled_c;
use modules\selfserve\config\selfserve_machine_wash_minutes_included_c;
use modules\selfserve\config\selfserve_minute_product_c;
use traits\module_config_t;
@@ -21,6 +23,11 @@ class selfserve_c
* @var selfserve_enabled_c $enabled
*/
public selfserve_enabled_c $enabled;
/**
* Whether machine wash is available in customer-facing self-serve flows
* @var selfserve_machine_wash_enabled_c $machine_wash_enabled
*/
public selfserve_machine_wash_enabled_c $machine_wash_enabled;
/**
* The product ID used for minute-based self-serve billing
* @var selfserve_minute_product_c $minute_product
@@ -42,11 +49,13 @@ class selfserve_c
$this->setupConfig('selfserve');
$this->allowUpdate([
selfserve_enabled_c::class,
selfserve_machine_wash_enabled_c::class,
selfserve_minute_product_c::class,
selfserve_machine_wash_minutes_included_c::class,
selfserve_dynamic_image_size_c::class
]);
$this->enabled = new selfserve_enabled_c();
$this->machine_wash_enabled = new selfserve_machine_wash_enabled_c();
$this->minute_product = new selfserve_minute_product_c();
$this->machine_wash_minutes_included = new selfserve_machine_wash_minutes_included_c();
$this->dynamic_image_size = new selfserve_dynamic_image_size_c();
@@ -0,0 +1,29 @@
<?php
namespace slack\config;
use Exception;
use traits\module_config_variable;
class slack_internal_department_goal_progress_webhook_url_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Slack',
'internal_department_goal_progress_webhook_url',
'string',
false,
null,
'Slack webhook URL used for internal department goal progress notifications',
'https://hooks.slack.com/services/...',
true,
''
);
}
}
@@ -0,0 +1,76 @@
<?php
namespace slack\config;
use Exception;
use traits\module_config_variable;
class slack_internal_department_ids_c
{
use module_config_variable {
setVariableValue as private traitSetVariableValue;
}
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Slack',
'internal_department_ids',
'string',
false,
null,
'JSON encoded department IDs considered internal departments for Slack goal progress notifications',
'[1,2,3]',
false,
'[]'
);
}
public function getDepartmentIds(): array
{
return self::normalizeDepartmentIds($this->getVariableValue());
}
/**
* @throws Exception
*/
public function setVariableValue(mixed $value): void
{
$this->traitSetVariableValue(json_encode(
self::normalizeDepartmentIds($value),
JSON_THROW_ON_ERROR
));
}
public static function normalizeDepartmentIds(mixed $value): array
{
if (is_string($value)) {
$decoded = json_decode($value, true);
if (is_array($decoded)) {
$value = $decoded;
} else {
$value = array_filter(array_map('trim', explode(',', $value)));
}
}
if (!is_array($value)) {
return [];
}
$departmentIds = [];
foreach ($value as $departmentId) {
$departmentId = (int)$departmentId;
if ($departmentId > 0) {
$departmentIds[] = $departmentId;
}
}
$departmentIds = array_values(array_unique($departmentIds));
sort($departmentIds);
return $departmentIds;
}
}
@@ -3,8 +3,12 @@
namespace slack;
require_once WD . '/modules/slack/config/slack_customer_registration_webhook_url_c.php';
require_once WD . '/modules/slack/config/slack_internal_department_goal_progress_webhook_url_c.php';
require_once WD . '/modules/slack/config/slack_internal_department_ids_c.php';
use slack\config\slack_customer_registration_webhook_url_c;
use slack\config\slack_internal_department_goal_progress_webhook_url_c;
use slack\config\slack_internal_department_ids_c;
use traits\module_config_t;
class slack_c
@@ -12,14 +16,20 @@ class slack_c
use module_config_t;
public slack_customer_registration_webhook_url_c $customer_registration_webhook_url;
public slack_internal_department_goal_progress_webhook_url_c $internal_department_goal_progress_webhook_url;
public slack_internal_department_ids_c $internal_department_ids;
public function __construct()
{
$this->setupConfig('Slack');
$this->allowUpdate([
slack_customer_registration_webhook_url_c::class,
slack_internal_department_goal_progress_webhook_url_c::class,
slack_internal_department_ids_c::class,
]);
$this->customer_registration_webhook_url = new slack_customer_registration_webhook_url_c();
$this->internal_department_goal_progress_webhook_url = new slack_internal_department_goal_progress_webhook_url_c();
$this->internal_department_ids = new slack_internal_department_ids_c();
}
}
@@ -379,6 +379,10 @@ class xlvask_usage_log extends xlvask_helper
private function unsetNullifiableProperties(): void
{
$nullable_review_metadata = [
'ignored_at',
'ignored_reason',
];
// Unset properties that are null or empty strings
$properties = [
'WashId', 'CustomerId', 'Customer', 'VatNumber', 'Location',
@@ -391,7 +395,10 @@ class xlvask_usage_log extends xlvask_helper
if ($this->isEmptyOrDefault($this->{$property})) {
$tmp_value = $this->{$property};
if ($tmp_value === $this->default_string || $tmp_value === $this->default_string_nullable) {
$this->{$property} = ''; // Set to null if it matches the default string
$this->{$property} = (
$tmp_value === $this->default_string_nullable
&& in_array($property, $nullable_review_metadata, true)
) ? null : '';
} elseif ($tmp_value === $this->default_int || $tmp_value === $this->default_int_nullable) {
if ($tmp_value === $this->default_int_nullable) {
$this->{$property} = null; // Set to null if it matches the default int nullable
@@ -37,6 +37,7 @@ class collected_order_invoices_o extends db
public object_property $updated_at;
public object_property $closed_at;
public int $economic_wash_subscription_user_id = 1857;
private ?array $last_economic_transfer_metrics = null;
/**
* The processor types
*
@@ -712,6 +713,7 @@ class collected_order_invoices_o extends db
*/
public function addInvoicesToDraft(bool $skip_check = false): self
{
$this->last_economic_transfer_metrics = null;
// Require the invoice collection to be selected
self::requireSelected();
// Require the invoice collection to be open
@@ -736,10 +738,20 @@ class collected_order_invoices_o extends db
usort($orders, function ($a, $b) {
return strtotime($a['created_at']) - strtotime($b['created_at']);
});
// Add the invoices to the invoice draft
// Add the invoice lines to the draft in one accumulated batch path.
$order_objects = [];
foreach ( $orders as $order ) {
self::addInvoiceToDraft($order['id'], true, $draft_id, $currency);
$order_object = new orders_o();
$order_object->select((int)$order['id']);
$order_object->requireSelected();
$order_objects[] = $order_object;
}
$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency);
$this->last_economic_transfer_metrics = [
'draft_invoice_id' => $draft_id,
'currency' => (string)$currency,
...$metrics,
];
// If the customer has the onlyTankCleaning attribute, add the environmental fee & oil fees to the invoice draft
self::addEnvironmentalAndOilFeesToDraft($draft_id, $currency);
// Object changed
@@ -748,6 +760,11 @@ class collected_order_invoices_o extends db
return $this;
}
public function getLastEconomicTransferMetrics(): ?array
{
return $this->last_economic_transfer_metrics;
}
/**
* Add the environmental fee & oil fees to the invoice draft, if the customer has the onlyTankCleaning attribute
* @param int $draft_id The invoice draft id
+18 -3
View File
@@ -22,6 +22,7 @@ class departments_o extends db
public object_property $dimension; // The dimension of the department
public object_property $visible; // The visibility of the department
public object_property $archived; // Whether the department is archived
public object_property $custom_pricing_only; // Whether missing department prices must not fall back to defaults
public object_property $branding; // The branding of the department
public object_property $longitude; // The longitude of the department (Can be null)
public object_property $latitude; // The latitude of the department (Can be null)
@@ -107,6 +108,7 @@ class departments_o extends db
$this->branding = new object_property($this->table, $this->id, 'branding', 'int', false);
$this->visible = new object_property($this->table, $this->id, 'visible', 'int', false);
$this->archived = new object_property($this->table, $this->id, 'archived', 'boolean', false);
$this->custom_pricing_only = new object_property($this->table, $this->id, 'custom_pricing_only', 'boolean', false);
$this->longitude = new object_property($this->table, $this->id, 'longitude', 'float', false);
$this->latitude = new object_property($this->table, $this->id, 'latitude', 'float', false);
$this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false);
@@ -185,6 +187,12 @@ class departments_o extends db
return $department;
}
public function isCustomPricingOnly(int $department_id): bool
{
$department = $this->getDepartmentById($department_id);
return (bool)(int)($department['custom_pricing_only'] ?? 0);
}
/**
* Get the price of a product in a department
* @param int $department_id
@@ -718,7 +726,7 @@ class departments_o extends db
* @return null|array
* @throws Exception
*/
public function sendSlackInternalStatisticNotification(string $date_start, string $date_end, array $department_ids, array $product_ids = [
public function sendSlackInternalStatisticNotification(string $date_start, string $date_end, array $department_ids = [], array $product_ids = [
25,
[23, 24], // Used to merge two products into one percentage (Spot Free)
22,
@@ -727,6 +735,11 @@ class departments_o extends db
26
], bool $return_as_array = false): null|array
{
$slack = new slack();
if (empty($department_ids)) {
$department_ids = $slack->get_internal_department_ids();
}
if (empty($department_ids)) {
throw new Exception('No department ids provided for the Slack internal statistic notification.');
}
@@ -847,9 +860,11 @@ class departments_o extends db
$array_of_results['daily_management'][] = $tmp;
// Send the message to the internal Slack webhook
$slack = new slack();
if (!$return_as_array) {
$slack->send_webhook_message($tmp, (new departments_o())->select(10)->slack_webhook->value());
$webhook = $slack->get_internal_department_goal_progress_webhook_url();
if ($webhook !== '') {
$slack->send_webhook_message($tmp, $webhook);
}
}
// Send a department-specific message for each internal department with the percentage of addons sold
foreach ( $department_ids as $department_id ) {
@@ -0,0 +1,87 @@
<?php
namespace objects;
use classes\db;
use classes\edge_gateway_schema_bootstrap;
use classes\object_property;
use traits\db_object_t;
class edge_gateway_expected_relay_states_o extends db
{
use db_object_t;
public object_property $gateway_id;
public object_property $department_id;
public object_property $lane_id;
public object_property $role;
public object_property $relay_id;
public object_property $device_id;
public object_property $local_ip;
public object_property $device_type;
public object_property $expected_state;
public object_property $updated_at;
public object_property $completed_at;
public object_property $last_attempted_at;
public object_property $last_error;
public object_property $metadata_json;
public object_property $created_at;
public object_property $row_updated_at;
public object_property $deleted_at;
public function structure(): void
{
edge_gateway_schema_bootstrap::ensureTables();
$this->setTable('edge_gateway_expected_relay_states');
}
public function getObjectProperties(): void
{
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
$this->lane_id = new object_property($this->table, $this->id, 'lane_id', 'int', false);
$this->role = new object_property($this->table, $this->id, 'role', 'string', false);
$this->relay_id = new object_property($this->table, $this->id, 'relay_id', 'string', false);
$this->device_id = new object_property($this->table, $this->id, 'device_id', 'string', false);
$this->local_ip = new object_property($this->table, $this->id, 'local_ip', 'string', false);
$this->device_type = new object_property($this->table, $this->id, 'device_type', 'string', false);
$this->expected_state = new object_property($this->table, $this->id, 'expected_state', 'bool', false);
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'string', false);
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'string', false);
$this->last_attempted_at = new object_property($this->table, $this->id, 'last_attempted_at', 'string', false);
$this->last_error = new object_property($this->table, $this->id, 'last_error', 'text', false);
$this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', false);
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
$this->row_updated_at = new object_property($this->table, $this->id, 'row_updated_at', 'timestamp', false);
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
}
public function objectChanged(): void
{
}
public function asArray(): array
{
$this->requireSelected();
return [
'id' => (int)$this->id,
'gateway_id' => (int)$this->gateway_id->value(),
'department_id' => (int)$this->department_id->value(),
'lane_id' => (int)$this->lane_id->value(),
'role' => (string)$this->role->value(),
'relay_id' => (string)$this->relay_id->value(),
'device_id' => $this->device_id->value() === null ? null : (string)$this->device_id->value(),
'local_ip' => $this->local_ip->value() === null ? null : (string)$this->local_ip->value(),
'device_type' => (string)$this->device_type->value(),
'state' => (bool)$this->expected_state->value(),
'updated' => (string)$this->updated_at->value(),
'completed' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(),
'last_attempted_at' => $this->last_attempted_at->value() === null ? null : (string)$this->last_attempted_at->value(),
'last_error' => $this->last_error->value() === null ? null : (string)$this->last_error->value(),
'metadata' => (array)($this->metadata_json->value() ?? []),
'created_at' => (string)$this->created_at->value(),
'row_updated_at' => $this->row_updated_at->value() === null ? null : (string)$this->row_updated_at->value(),
];
}
}
+10 -6
View File
@@ -3,6 +3,7 @@
namespace objects;
use classes\db;
use classes\customer_order_product_policy;
use classes\object_property;
use Exception;
use traits\db_object_t;
@@ -93,6 +94,7 @@ class order_items_o extends db
{
global $db, $response;
try {
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
// Avoid SQL injection
$reference = $db->escape_string($reference);
$notes = $db->escape_string($notes);
@@ -167,18 +169,20 @@ class order_items_o extends db
try {
// Get the order
$order = (new orders_o())->getOrderById($order_id);
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
// Get the product price
$price = (new products_o())->getProductById($product_id)->getDepartmentPrice((int)$order->department_id->value());
$product = (new products_o())->getProductById($product_id);
$priceResolution = $product->getDepartmentPriceResolution((int)$order->department_id->value());
$price = $priceResolution['price'];
// Check if the user has a discount on the product, or category
$customer = (new orders_o())->getOrderCustomer($order_id);
$discount = $customer->getCustomPrice($product_id, false);
if ($discount) {
$price = $price - ($price * $discount / 100);
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
$price = $customer->applyProductCustomerPricing($product_id, (int)$price, false);
}
// If the price is forced, set the price to the forced price
if ($forcePrice) {
if ($forcePrice !== null) {
$price = (int)$forcePrice;
}
@@ -354,4 +358,4 @@ class order_items_o extends db
{
return (new products_o())->select((int)$this->product_id->value());
}
}
}
+17 -12
View File
@@ -1428,15 +1428,16 @@ class orders_o extends db
$order_item->product_id->set((int)$product->id); // Set the product ID to the product ID from the wash item
$order_item->reference->set('');
// Get the product price based on the department
$product_price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
$product_price = (int)$priceResolution['price']; // Get the department price for the product
// Get the customers custom price discount percentage
$user = $xlvask_usage_log->getUser(); // Get the user from the usage log
if (!$user->exists()) {
throw new Exception('No user found matching the customer number in the usage log');
}
$product_price_discount_percentage = (int)$user->getProductDiscountPercentage((int)$order_item->product_id->value()); // Get the custom price discount percentage for the product
// Apply the discount percentage to the product price
$product_price = (int)round($product_price * (1 - ($product_price_discount_percentage / 100))); // Apply the discount percentage to the product price
if (!products_o::priceResolutionIsCustomMissing($priceResolution)) {
$product_price = $user->applyProductCustomerPricing((int)$order_item->product_id->value(), (int)$product_price);
}
$order_item->notes->set(null); // Set notes for the simulated order item
$order_item->price->set((int)$product_price); // Set the price based on the product price and discount percentage
$order_item->quantity->set((int)$washItem->Count); // Set the quantity based on the wash item
@@ -1503,11 +1504,12 @@ class orders_o extends db
if (!$current_user->exists()) {
throw new Exception('No current user found');
}
$price = (int)$product->getDepartmentPrice((int)$this->department_id->value()); // Get the department price for the product
$discount_percentage = (int)$current_user->getProductDiscountPercentage((int)$product->id); // Get the custom price discount percentage for the product
// Apply the discount percentage to the product price
// Apply the discount percentage to the product price
return (int)round($price * (1 - ($discount_percentage / 100)));
$priceResolution = $product->getDepartmentPriceResolution((int)$this->department_id->value());
$price = (int)$priceResolution['price']; // Get the department price for the product
if (products_o::priceResolutionIsCustomMissing($priceResolution)) {
return $price;
}
return $current_user->applyProductCustomerPricing((int)$product->id, $price);
}
/**
@@ -1589,13 +1591,16 @@ class orders_o extends db
$product_id = (int)$item['product_id'];
if (!isset($department_price_cache[$product_id])) {
$product = (new products_o())->select($product_id);
$department_price_cache[$product_id] = (int)$product->getDepartmentPrice($department_id);
$department_price_cache[$product_id] = $product->getDepartmentPriceResolution($department_id);
}
if ($tmp_user === null) {
$tmp_user = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
}
$discount = $tmp_user->getCustomPrice($product_id, false);
$post_discount = (int)round($department_price_cache[$product_id] * (1 - ($discount / 100))) * $quantity;
$unitPrice = (int)$department_price_cache[$product_id]['price'];
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$product_id])) {
$unitPrice = $tmp_user->applyProductCustomerPricing($product_id, $unitPrice, false);
}
$post_discount = $unitPrice * $quantity;
$total += $post_discount;
}
+63 -13
View File
@@ -13,6 +13,11 @@ class products_o extends db
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi';
public const CUSTOM_PRICING_MISSING_PRICE = 999999;
public const PRICE_SOURCE_DEPARTMENT = 'department';
public const PRICE_SOURCE_DEFAULT = 'default';
public const PRICE_SOURCE_CUSTOM_MISSING = 'custom_missing';
public const PRICE_SOURCE_KEY = '_department_price_source';
/**
* The name of the product
@@ -255,24 +260,41 @@ class products_o extends db
* @param int $department_id
* @return array
*/
public function applyDepartmentPricing(array $products, int $department_id): array
public function applyDepartmentPricing(array $products, int $department_id, bool $includePriceSource = false): array
{
global $db;
$department_id = $db->escape_string($department_id);
$sql = "SELECT * FROM product_department_prices WHERE department_id = $department_id";
$result = $db->query($sql);
$prices = $db->fetch_all($result);
$priceLookup = [];
foreach ($prices as $price) {
$priceLookup[(int)$price['product_id']] = (int)$price['price'];
}
$customPricingOnly = (new departments_o())->isCustomPricingOnly((int)$department_id);
foreach ( $products as $key => $product ) {
foreach ( $prices as $price ) {
if ((int)$product['id'] === (int)$price['product_id']) {
$products[$key]['price'] = $price['price'];
}
$productId = (int)($product['id'] ?? 0);
$source = self::PRICE_SOURCE_DEFAULT;
if (array_key_exists($productId, $priceLookup)) {
$products[$key]['price'] = $priceLookup[$productId];
$source = self::PRICE_SOURCE_DEPARTMENT;
} elseif ($customPricingOnly) {
$products[$key]['price'] = self::CUSTOM_PRICING_MISSING_PRICE;
$source = self::PRICE_SOURCE_CUSTOM_MISSING;
}
if ($includePriceSource) {
$products[$key][self::PRICE_SOURCE_KEY] = $source;
}
}
return $products;
}
public function getDepartmentPrice(int $department_id): int
/**
* @return array{price:int,source:string}
*/
public function getDepartmentPriceResolution(int $department_id): array
{
global $db;
$department_id = $db->escape_string($department_id);
@@ -281,10 +303,27 @@ class products_o extends db
$prices = $db->fetch_all($result);
// Check if the product has a department price
if (count($prices) > 0) {
return $prices[0]['price'];
return [
'price' => (int)$prices[0]['price'],
'source' => self::PRICE_SOURCE_DEPARTMENT,
];
}
if ((new departments_o())->isCustomPricingOnly((int)$department_id)) {
return [
'price' => self::CUSTOM_PRICING_MISSING_PRICE,
'source' => self::PRICE_SOURCE_CUSTOM_MISSING,
];
}
// Return the default price
return $this->price->value();
return [
'price' => (int)$this->price->value(),
'source' => self::PRICE_SOURCE_DEFAULT,
];
}
public function getDepartmentPrice(int $department_id): int
{
return $this->getDepartmentPriceResolution($department_id)['price'];
}
public function applyCustomerDiscounts(array $products, users_o $customer): array
@@ -300,15 +339,26 @@ class products_o extends db
if (!isset($product['id']) || !isset($product['price'])) {
throw new \InvalidArgumentException('Invalid product array, must contain id and price keys');
}
// Get the customer's discount percentage
$discount_percentage = $customer->getProductDiscountPercentage($product['id']);
// Apply the discount to the product price
if ($discount_percentage > 0) {
$product['price'] = (int)(round($product['price'] * (1 - ($discount_percentage / 100))));
if (($product[self::PRICE_SOURCE_KEY] ?? null) !== self::PRICE_SOURCE_CUSTOM_MISSING) {
$product['price'] = $customer->applyProductCustomerPricing((int)$product['id'], (int)$product['price']);
}
unset($product[self::PRICE_SOURCE_KEY]);
return $product;
}
public static function stripDepartmentPriceSources(array $products): array
{
return array_map(static function (array $product): array {
unset($product[self::PRICE_SOURCE_KEY]);
return $product;
}, $products);
}
public static function priceResolutionIsCustomMissing(array $resolution): bool
{
return ($resolution['source'] ?? null) === self::PRICE_SOURCE_CUSTOM_MISSING;
}
public function getSubscriptionMonthlyPrice(): int
{
// Subscription price (for 2 washes per month) is 1.2 times the normal price
@@ -23,11 +23,11 @@ class subuser_grants_o extends db
public object_property $deleted_at;
const defaultPermissions = [
'VEHICLES_LIST',
'SELFSERVE_LIST',
'SELFSERVE_ADD',
'BOOKINGS_LIST',
'BOOKINGS_ADD',
'BOOKINGS_EDIT',
'BOOKINGS_DELETE',
'ORDERS_LIST',
];
public static function normalizePermissionsValue(mixed $raw): array
+19 -3
View File
@@ -171,6 +171,7 @@ class subusers_o extends db
return $this;
} catch (Exception $e) {
$response->error($e->getMessage());
throw $e;
}
}
@@ -217,9 +218,16 @@ class subusers_o extends db
throw new RandomException('Error generating random bytes for setup token', 0, $e);
}
$cache_key = 'setup_token:' . $token;
$cashe_object_id = 'subuser_setup_token';
$this->cache($cache_key, $this->id, $cashe_object_id);
$this->setCachedExpiration($cache_key, 24 * 60 * 60, $cashe_object_id); // Set the cache expiration to 24 hours
$reverse_cache_key = 'setup_token_for_subuser:' . (int)$this->id;
$cache_object_id = 'subuser_setup_token';
$existingToken = $this->getCached($reverse_cache_key, $cache_object_id);
if (is_string($existingToken) && $existingToken !== '') {
$this->deleteCached('setup_token:' . $existingToken, $cache_object_id);
}
$this->cache($cache_key, $this->id, $cache_object_id);
$this->setCachedExpiration($cache_key, 24 * 60 * 60, $cache_object_id); // Set the cache expiration to 24 hours
$this->cache($reverse_cache_key, $token, $cache_object_id);
$this->setCachedExpiration($reverse_cache_key, 24 * 60 * 60, $cache_object_id);
return $token;
}
@@ -260,8 +268,16 @@ class subusers_o extends db
public function invalidateSetupToken(string $token): void
{
$object_id = 'subuser_setup_token';
$subuser_id = $this->getSubuserIdBySetupToken($token);
$cache_key = 'setup_token:' . $token;
$this->deleteCached($cache_key, $object_id);
if ($subuser_id !== null) {
$reverse_cache_key = 'setup_token_for_subuser:' . (int)$subuser_id;
$currentToken = $this->getCached($reverse_cache_key, $object_id);
if ((string)$currentToken === $token) {
$this->deleteCached($reverse_cache_key, $object_id);
}
}
}
/**
@@ -4,6 +4,8 @@ namespace objects;
use classes\db;
use classes\object_property;
use classes\price_overrides_schema_bootstrap;
use classes\system_search_cache;
use traits\db_object_t;
class user_price_overrides_o extends db
@@ -14,10 +16,12 @@ class user_price_overrides_o extends db
public object_property $is_category;
public object_property $product_or_category_id;
public object_property $percentage;
public object_property $fixed_price;
public function structure(): void
{
$this->setTable('price_overrides');
price_overrides_schema_bootstrap::ensureColumns();
}
public function objectChanged(): void
@@ -30,6 +34,7 @@ class user_price_overrides_o extends db
$this->is_category = new object_property($this->table, $this->id, 'is_category', 'bool', true);
$this->product_or_category_id = new object_property($this->table, $this->id, 'product_or_category_id', 'int', true);
$this->percentage = new object_property($this->table, $this->id, 'percentage', 'int', true);
$this->fixed_price = new object_property($this->table, $this->id, 'fixed_price', 'int', false, null);
}
public function setUser($user_id): user_price_overrides_o
@@ -43,39 +48,41 @@ class user_price_overrides_o extends db
* @param bool $is_category
* @param int|string $product_or_category_id
* @param int $percentage
* @param int|null $fixed_price
* @return $this
*/
public function setPrice(bool $is_category, int|string $product_or_category_id, int $percentage): user_price_overrides_o
public function setPrice(bool $is_category, int|string $product_or_category_id, int $percentage, ?int $fixed_price = null): user_price_overrides_o
{
global $db;
// If the user is not set, return the object
if (!isset($this->user_id)) {
return $this;
}
if ($is_category) {
$fixed_price = null;
}
// Check if the record already exists
$this->removePriceIfExist($is_category, $product_or_category_id);
// If the percentage is 0, return the object
if ($percentage === 0) {
// If neither a discount nor a fixed product price is set, remove the record.
if ($percentage === 0 && $fixed_price === null) {
return $this;
}
// Create a new record in the database
$sql = "INSERT INTO $this->table (user_id, is_category, product_or_category_id, percentage) VALUES ($this->user_id, " . (int)$is_category . ", '$product_or_category_id', $percentage)";
$product_or_category_id = $db->escape_string((string)$product_or_category_id);
$fixed_price_sql = $fixed_price === null ? 'NULL' : (string)max(0, (int)$fixed_price);
$sql = "INSERT INTO $this->table (user_id, is_category, product_or_category_id, percentage, fixed_price) VALUES (" . (int)$this->user_id . ", " . (int)$is_category . ", '$product_or_category_id', " . (int)$percentage . ", $fixed_price_sql)";
$db->query($sql);
$this->markSearchDirty();
return $this;
}
private function removePriceIfExist(bool $is_category, int|string $product_or_category_id): void
{
global $db;
// Get the price override from the database
$sql = "SELECT * FROM $this->table WHERE user_id = " . $this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'";
$result = $db->query($sql);
if ($result->num_rows > 0) {
// Remove the record from the database
$sql = "DELETE FROM $this->table WHERE user_id = " . $this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'";
$db->query($sql);
}
$product_or_category_id = $db->escape_string((string)$product_or_category_id);
$sql = "DELETE FROM $this->table WHERE user_id = " . (int)$this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id'";
$db->query($sql);
$this->markSearchDirty();
}
/**
@@ -132,6 +139,49 @@ class user_price_overrides_o extends db
return $percentage;
}
public function getFixedPrice(bool $is_category, int|string $product_or_category_id): ?int
{
if ($is_category || !isset($this->user_id)) {
return null;
}
$row = $this->getDirectPriceRow(false, (int)$product_or_category_id);
if ($row === null || $row['fixed_price'] === null) {
return null;
}
return (int)$row['fixed_price'];
}
public function getDirectPriceRow(bool $is_category, int|string $product_or_category_id): ?array
{
global $db;
if (!isset($this->user_id)) {
return null;
}
$product_or_category_id = $db->escape_string((string)$product_or_category_id);
$sql = "SELECT * FROM $this->table WHERE user_id = " . (int)$this->user_id . " AND is_category = " . (int)$is_category . " AND product_or_category_id = '$product_or_category_id' LIMIT 1";
$result = $db->query($sql);
if (!$result || $result->num_rows < 1) {
return null;
}
$row = $result->fetch_assoc();
$row['id'] = (int)$row['id'];
$row['user_id'] = (int)$row['user_id'];
$row['is_category'] = (bool)$row['is_category'];
$row['product_or_category_id'] = $is_category
? (string)$row['product_or_category_id']
: (int)$row['product_or_category_id'];
$row['percentage'] = (int)$row['percentage'];
$row['fixed_price'] = array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null
? (int)$row['fixed_price']
: null;
return $row;
}
/**
* Get all the price overrides for the user
* @return array
@@ -153,6 +203,7 @@ class user_price_overrides_o extends db
$row['is_category'] = (bool)$row['is_category'];
$row['product_or_category_id'] = (int)$row['product_or_category_id'];
$row['percentage'] = (int)$row['percentage'];
$row['fixed_price'] = array_key_exists('fixed_price', $row) && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null;
$row['created_at'] = (string)$row['created_at'];
$row['updated_at'] = (string)$row['updated_at'];
// Add the row to the list
@@ -167,10 +218,19 @@ class user_price_overrides_o extends db
'is_category' => true,
'product_or_category_id' => "global",
'percentage' => (int)$economic_user_global_discount,
'fixed_price' => null,
'created_at' => "2021-01-01 00:00:00",
'updated_at' => "2021-01-01 00:00:00"
];
}
return $prices;
}
}
private function markSearchDirty(): void
{
try {
system_search_cache::markDirtyTable($this->table);
} catch (\Throwable) {
}
}
}
+84 -2
View File
@@ -981,6 +981,31 @@ class users_o extends db
return $discount_percentage === null ? 0 : (int)$discount_percentage;
}
public function getProductFixedPrice(int $product_id): ?int
{
self::requireSelected();
return $this->price_overrides->setUser($this->id)->getFixedPrice(false, $product_id);
}
public function applyProductCustomerPricing(int $product_id, int $base_price, bool $use_final_price_discount_calculation = true): int
{
self::requireSelected();
$fixed_price = $this->getProductFixedPrice($product_id);
if ($fixed_price !== null) {
return $fixed_price;
}
$discount_percentage = $use_final_price_discount_calculation
? (int)$this->getProductDiscountPercentage($product_id)
: (int)$this->getCustomPrice($product_id, false);
if ($discount_percentage <= 0) {
return $base_price;
}
return (int)round($base_price * (1 - ($discount_percentage / 100)));
}
/**
@@ -1077,9 +1102,65 @@ class users_o extends db
return $tmp;
}
/**
* @param array<int, array<string, mixed>> $users
* @return array<int, array<string, mixed>>
*/
public function markLimitedBackofficeManagedUsers(array $users): array
{
$userIds = [];
foreach ($users as $user) {
$userId = (int)($user['id'] ?? 0);
if ($userId > 0) {
$userIds[$userId] = true;
}
}
if ($userIds === []) {
return $users;
}
global $db;
$rows = $db->fetch_all($db->query(
'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `user_id` IN (' .
implode(',', array_map('intval', array_keys($userIds))) .
')'
));
$managedUserIds = [];
foreach ($rows as $row) {
$managedUserIds[(int)$row['user_id']] = true;
}
foreach ($users as $key => $user) {
$users[$key]['limited_backoffice_managed'] = isset($managedUserIds[(int)($user['id'] ?? 0)]);
}
return $users;
}
public function isLimitedBackofficeManagedUser(int $userId): bool
{
if ($userId <= 0) {
return false;
}
global $db;
$result = $db->query(
'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `user_id` = ' . (int)$userId . ' LIMIT 1'
);
return $result !== false && $result->num_rows > 0;
}
public function parseCustomerNumbers(array $listObjectsWithPaginationIfSet): array
{
foreach ( $listObjectsWithPaginationIfSet as $key => $value ) {
if ((bool)($value['limited_backoffice_managed'] ?? false) || (int)($value['customer_number'] ?? -1) === 0) {
$listObjectsWithPaginationIfSet[$key]['customer_name'] = $value['display_name'] ?? null;
continue;
}
$listObjectsWithPaginationIfSet[$key]['customer_name'] = $this->getCustomerNameById($value['id']);
}
return $listObjectsWithPaginationIfSet;
@@ -1183,15 +1264,16 @@ class users_o extends db
* @param int $object_id The ID of the object
* @param int $discount_percentage The discount percentage
* @param bool $is_category If the object is a category
* @param int|null $fixed_price The fixed product price, when set
* @return void
*/
public function setCustomPrice(int $user_id, int|string $object_id, int $discount_percentage, bool $is_category = false): void
public function setCustomPrice(int $user_id, int|string $object_id, int $discount_percentage, bool $is_category = false, ?int $fixed_price = null): void
{
$this->id = $user_id;
// Get the user object properties
$this->getObjectProperties();
// Set the custom price (key = 'custom_price')
$this->price_overrides->setUser($this->id)->setPrice($is_category, $object_id, $discount_percentage);
$this->price_overrides->setUser($this->id)->setPrice($is_category, $object_id, $discount_percentage, $fixed_price);
}
public function syncAllUsersEconomicCustomerDetails(): void
@@ -82,7 +82,7 @@ class xlvask_usage_logs_o extends db
$this->CustomerGuid = new object_property($this->table, $this->id, 'CustomerGuid', 'string', false);
$this->VehicleId = new object_property($this->table, $this->id, 'VehicleId', 'string', false);
$this->WashItems = new object_property($this->table, $this->id, 'WashItems', 'string', false);
$this->ignored_at = new object_property($this->table, $this->id, 'ignored_at', 'string', false);
$this->ignored_at = new object_property($this->table, $this->id, 'ignored_at', 'datetime', false);
$this->ignored_by = new object_property($this->table, $this->id, 'ignored_by', 'int', false);
$this->ignored_reason = new object_property($this->table, $this->id, 'ignored_reason', 'string', false);
}
@@ -195,18 +195,20 @@ class xlvask_usage_logs_o extends db
/**
* Import the usage logs from XL Vask
* @param string $dateTimeModifier A date time modifier to use for the import, defaults to '-7 days'
* @param string|null $dateFrom Optional import start date or date-time modifier. Defaults to '-7 days'.
* @param string|null $dateTo Optional inclusive import end date.
* @throws Exception If the objects were not successfully added.
* @returns void
*/
public function importUsageLogs(string $dateTimeModifier = '-7 days'): void
public function importUsageLogs(?string $dateFrom = null, ?string $dateTo = null): void
{
if (!empty($this->id)) {
throw new Exception('To prevent issues, having a selected object is not allowed.');
}
$usage_logs = $this->getUsageLogsFromXLVask(
date('Y-m-d\TH:i:s.000', strtotime($dateTimeModifier)) // Example: '2025-05-01T00:00:00.000'
self::formatImportDateFrom($dateFrom) // Example: '2025-05-01T00:00:00.000'
);
$usage_logs = self::filterUsageLogsUntil($usage_logs, $dateTo);
/** @var string[] $known_usage_logIds The XL Vask usage logIds currently known */
$known_usage_logIds = array_map(function ($log) {
return $log['WashId'];
@@ -236,6 +238,46 @@ class xlvask_usage_logs_o extends db
unset($new_usage_logs);
}
private static function formatImportDateFrom(?string $dateFrom): string
{
$dateFrom = trim((string)($dateFrom ?? ''));
$timestamp = strtotime($dateFrom === '' ? '-7 days' : $dateFrom);
if ($timestamp === false) {
throw new Exception('Invalid XL Vask usage import dateFrom');
}
return date('Y-m-d\TH:i:s.000', $timestamp);
}
/**
* @param xlvask_usage_log[] $usageLogs
* @return xlvask_usage_log[]
* @throws Exception
*/
private static function filterUsageLogsUntil(array $usageLogs, ?string $dateTo): array
{
$dateTo = trim((string)($dateTo ?? ''));
if ($dateTo === '') {
return $usageLogs;
}
$dateToTimestamp = strtotime($dateTo);
if ($dateToTimestamp === false) {
throw new Exception('Invalid XL Vask usage import dateTo');
}
$inclusiveEndTimestamp = strtotime(date('Y-m-d 23:59:59', $dateToTimestamp));
if ($inclusiveEndTimestamp === false) {
throw new Exception('Invalid XL Vask usage import dateTo');
}
return array_values(array_filter($usageLogs, function (xlvask_usage_log $log) use ($inclusiveEndTimestamp) {
$startTimestamp = strtotime((string)$log->StartTime);
return $startTimestamp !== false && $startTimestamp <= $inclusiveEndTimestamp;
}));
}
/**
* This function retrieves the usage logs from XL Vask
* @param string $fromDate The date from which to retrieve the usage logs, in ISO 8601 format (e.g., '2025-05-01T00:00:00.000')
+252 -9
View File
@@ -2595,6 +2595,12 @@ paths:
type: string
description: Contact person name
example: "Mikkel"
ean:
type: string
description: Optional EAN used for e-invoicing in e-conomic
maxLength: 13
pattern: '^[0-9]{1,13}$'
example: "5790001234567"
g_recaptcha_response:
type: string
description: reCAPTCHA verification token
@@ -3092,7 +3098,7 @@ paths:
get:
tags:
- Users
summary: Get user discounts
summary: Get user discounts and product fixed prices
operationId: getUserDiscounts
parameters:
- name: user_id
@@ -3108,7 +3114,7 @@ paths:
post:
tags:
- Users
summary: Set user discount
summary: Set user discount or product fixed price
operationId: setUserDiscount
requestBody:
required: true
@@ -3122,6 +3128,11 @@ paths:
discount: {type: integer}
object_id: {type: string}
is_category: {type: boolean}
fixed_price:
type: integer
nullable: true
minimum: 0
description: Optional product-only fixed price. Omit to preserve the current fixed price, send null to clear it.
responses:
'200':
description: Success
@@ -7462,7 +7473,7 @@ paths:
application/json:
schema:
type: object
required: [reg, type, wash_subscription]
required: [reg, type]
properties:
reg:
type: string
@@ -7474,6 +7485,8 @@ paths:
description: Product ID representing the vehicle wash type
wash_subscription:
type: boolean
default: false
description: Defaults to false when omitted.
reference:
type: string
maxLength: 255
@@ -8651,6 +8664,12 @@ paths:
email: {type: string}
phone: {type: integer}
name: {type: string}
ean:
type: string
description: Optional EAN used for e-invoicing in e-conomic
maxLength: 13
pattern: '^[0-9]{1,13}$'
example: "5790001234567"
responses:
'200':
description: Success
@@ -11682,6 +11701,53 @@ paths:
'502':
description: Slack customer registration webhook test failed
/slack/config/internal-department-goal-progress:
get:
tags: [Config]
summary: Get Slack internal department goal progress config
operationId: getSlackInternalDepartmentGoalProgressConfig
responses:
'200':
description: Slack internal department goal progress configuration retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfigResponse'
post:
tags: [Config]
summary: Update Slack internal department goal progress config
operationId: updateSlackInternalDepartmentGoalProgressConfig
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfigUpdate'
responses:
'200':
description: Slack internal department goal progress configuration updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfigResponse'
/slack/config/internal-department-goal-progress/test:
post:
tags: [Config]
summary: Test Slack internal department goal progress webhook
operationId: testSlackInternalDepartmentGoalProgressWebhook
responses:
'200':
description: Slack internal department goal progress webhook test completed successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SlackConfigTestResponse'
'400':
description: Slack internal department goal progress webhook URL is not configured
'502':
description: Slack internal department goal progress webhook test failed
/backups/config:
get:
tags: [Config]
@@ -12356,6 +12422,29 @@ paths:
application/json:
schema: {}
/roles/limited-backoffice-permission-templates:
get:
tags:
- Roles
summary: List limited backoffice permission templates
operationId: listLimitedBackofficeRolePermissionTemplates
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
type: object
properties:
key: {type: string}
label: {type: string}
description: {type: string}
permissions:
type: array
items: {type: string}
/roles/permissions:
post:
tags:
@@ -12474,6 +12563,40 @@ paths:
application/json:
schema: {}
/superuser/departments/{id}/overview:
get:
tags:
- Departments
summary: Get superuser department overview
description: Returns the selected department metadata and operational overview metrics for a superuser without requiring scoped department access.
operationId: getSuperuserDepartmentOverview
parameters:
- name: id
in: path
required: true
schema: {type: integer}
- name: date
in: query
required: true
schema: {type: string}
- name: date_to
in: query
required: false
schema: {type: string}
responses:
'200':
description: Superuser department overview loaded successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SuperuserDepartmentOverviewResponse'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/superuser/department/branding:
put:
tags:
@@ -12668,6 +12791,33 @@ paths:
schema:
$ref: '#/components/schemas/DepartmentDailyReportOverviewResponse'
/departments/daily-reports/product-targets:
put:
tags:
- Departments
summary: Set daily report product target
description: Requires set_department_daily_report_product_targets and department_access_:department_id. Send a null target_percentage to clear the target.
operationId: setDailyReportProductTarget
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportProductTargetRequest'
responses:
'200':
description: Daily report product target updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportProductTargetResponse'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/departments/daily-reports/get:
get:
tags:
@@ -13339,7 +13489,6 @@ components:
- expected
- actual
- data_collection_accepted
- screenshot
properties:
before_error:
type: string
@@ -13355,10 +13504,11 @@ components:
description: What actually happened
data_collection_accepted:
type: boolean
description: Required acceptance of collecting screenshot and diagnostic error data
description: Required acceptance of collecting diagnostic error data and a screenshot when one can be attached
screenshot:
type: string
description: PNG, JPEG, or WebP data URI of the current app viewport
nullable: true
description: Optional PNG, JPEG, or WebP data URI of the current app viewport. Reports are accepted without an attachment when capture or upload fails.
route_path:
type: string
nullable: true
@@ -13469,6 +13619,7 @@ components:
nullable: true
screenshot:
type: object
nullable: true
additionalProperties: true
answers:
type: object
@@ -15200,11 +15351,14 @@ components:
type: object
properties:
module: { type: string, enum: [Slack] }
variable: { type: string, enum: [customer_registration_webhook_url] }
variable: { type: string, enum: [customer_registration_webhook_url, internal_department_goal_progress_webhook_url, internal_department_ids] }
type: { type: string, enum: [string] }
value:
type: string
example: https://hooks.slack.com/services/...
oneOf:
- type: string
example: https://hooks.slack.com/services/...
- type: string
example: '[1,2,3]'
required: [module, variable, type, value]
SlackConfigTestResult:
@@ -15215,6 +15369,42 @@ components:
message: { type: string }
required: [configured, sent, message]
SlackInternalDepartmentGoalProgressDepartment:
type: object
properties:
id: { type: integer }
name: { type: string }
order_priority: { type: integer }
required: [id, name, order_priority]
SlackInternalDepartmentGoalProgressConfig:
type: object
properties:
internal_department_goal_progress_webhook_url:
type: string
example: https://hooks.slack.com/services/...
internal_department_ids:
type: array
items: { type: integer }
example: [1, 2, 3]
departments:
type: array
items:
$ref: '#/components/schemas/SlackInternalDepartmentGoalProgressDepartment'
required: [internal_department_goal_progress_webhook_url, internal_department_ids, departments]
SlackInternalDepartmentGoalProgressConfigUpdate:
type: object
properties:
internal_department_goal_progress_webhook_url:
type: string
example: https://hooks.slack.com/services/...
internal_department_ids:
type: array
items: { type: integer }
example: [1, 2, 3]
required: [internal_department_goal_progress_webhook_url, internal_department_ids]
BackupsConfigEntry:
type: object
properties:
@@ -15499,6 +15689,14 @@ components:
data: { $ref: '#/components/schemas/SlackConfigTestResult' }
required: [data]
SlackInternalDepartmentGoalProgressConfigResponse:
allOf:
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
- type: object
properties:
data: { $ref: '#/components/schemas/SlackInternalDepartmentGoalProgressConfig' }
required: [data]
BackupsConfigListResponse:
allOf:
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
@@ -21429,6 +21627,36 @@ components:
state: { type: string }
value: { type: integer }
out_of: { type: integer }
target_percentage: { type: number, format: float, nullable: true }
target_department_id: { type: integer, nullable: true }
DepartmentDailyReportProductTargetRequest:
type: object
required:
- department_id
- product_id
- target_percentage
properties:
department_id: { type: integer }
product_id: { type: integer }
target_percentage:
type: number
format: float
nullable: true
DepartmentDailyReportProductTarget:
type: object
properties:
department_id: { type: integer }
product_id: { type: integer }
target_percentage: { type: number, format: float, nullable: true }
DepartmentDailyReportProductTargetResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
$ref: '#/components/schemas/DepartmentDailyReportProductTarget'
DepartmentDailyReportOverviewPayload:
type: object
@@ -21454,6 +21682,21 @@ components:
data:
$ref: '#/components/schemas/DepartmentDailyReportOverviewPayload'
SuperuserDepartmentOverviewPayload:
type: object
properties:
department:
$ref: '#/components/schemas/Department'
overview:
$ref: '#/components/schemas/DepartmentDailyReportOverviewPayload'
SuperuserDepartmentOverviewResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
$ref: '#/components/schemas/SuperuserDepartmentOverviewPayload'
DepartmentDailyReportTransactionCountPayload:
type: object
properties:
@@ -3,9 +3,8 @@ FROM ${BASE_IMAGE}
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose libcurl4-openssl-dev libsqlite3-dev; \
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose; \
php -r 'foreach (["curl", "sqlite3", "pdo_sqlite"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
rm -rf /var/lib/apt/lists/*
COPY auto-updater.php /usr/local/bin/auto-updater.php
@@ -1,12 +1,7 @@
ARG BASE_IMAGE=php:8.2-cli-bookworm
FROM ${BASE_IMAGE}
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
rm -rf /var/lib/apt/lists/*
RUN php -r 'foreach (["curl", "sqlite3", "pdo_sqlite"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
WORKDIR /opt/truckwash-edge-agent
@@ -1,12 +1,7 @@
ARG BASE_IMAGE=php:8.2-cli-bookworm
FROM ${BASE_IMAGE}
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
rm -rf /var/lib/apt/lists/*
RUN php -r 'foreach (["curl", "sqlite3", "pdo_sqlite"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
WORKDIR /opt/truckwash-edge-agent
@@ -727,11 +727,20 @@ final class BrokerWebSocketClient
throw new RuntimeException('Invalid broker websocket URL: ' . $socketUrl);
}
$host = (string)$parts['host'];
$path = (string)($parts['path'] ?? '/');
if ($host === 'edge-broker' && ($path === '/edge-broker' || str_starts_with($path, '/edge-broker/'))) {
$path = substr($path, strlen('/edge-broker'));
if ($path === '') {
$path = '/';
}
}
return [
'scheme' => strtolower((string)($parts['scheme'] ?? 'ws')),
'host' => (string)$parts['host'],
'host' => $host,
'port' => (int)($parts['port'] ?? (((string)($parts['scheme'] ?? 'ws')) === 'wss' ? 443 : 80)),
'path' => (string)($parts['path'] ?? '/')
'path' => $path
. (isset($parts['query']) && trim((string)$parts['query']) !== '' ? '?' . $parts['query'] : ''),
];
}
@@ -997,9 +1006,16 @@ final class TruckwashEdgeAgent
private const DEFAULT_WORKER_BASE_URL = 'http://lan-worker:8090';
private const DEFAULT_UPDATE_WINDOW = '02:00-04:00';
private const OPERATION_COMPLETE_TIMEOUT_SECONDS = 120;
private const OUTBOX_REPLAY_BATCH_LIMIT = 3;
private const OUTBOX_REPLAY_TIMEOUT_SECONDS = 3;
private const OUTBOX_OPERATION_COMPLETE_REPLAY_TIMEOUT_SECONDS = 10;
private const OUTBOX_REPLAY_FAILURE_COOLDOWN_SECONDS = 15;
private const MACHINE_SIGNAL_TIMEOUT_SECONDS = 3;
private const BROKER_MESSAGE_PUMP_LIMIT = 12;
private const BROKER_CONNECTED_COMMAND_POLL_INTERVAL_SECONDS = 1;
private const LOOP_STALE_AFTER_SECONDS = 30;
private const CONTROL_PLANE_SYNC_STALE_AFTER_SECONDS = 90;
private const MAX_RELAY_TOGGLE_AFTER_SECONDS = 5;
private AgentConfig $config;
private HttpJsonClient $http;
@@ -1017,7 +1033,9 @@ final class TruckwashEdgeAgent
private string $stagedUpdatePath;
private int $lastHeartbeatAt = 0;
private int $lastMachineSignalPollAt = 0;
private int $lastBrokerConnectedCommandPollAt = 0;
private int $lastMachineSignalMonitorRefreshAt = 0;
private int $lastOutboxFailureAt = 0;
private ?array $lastControlPlaneResponse = null;
private string $agentInstanceId;
@@ -1061,7 +1079,6 @@ final class TruckwashEdgeAgent
$this->reloadConfigFromDisk();
$this->ensureClaimed();
$this->configureBrokerClient();
$this->flushOutbox();
$this->pumpBrokerTransport();
$this->heartbeat();
$this->pollMachineStartSignals();
@@ -1069,12 +1086,13 @@ final class TruckwashEdgeAgent
$this->pumpBrokerTransport();
continue;
}
$brokerConnected = $this->isBrokerConnected();
$processedManagementOperation = false;
if (!$this->isBrokerConnected()) {
if (!$brokerConnected) {
$processedManagementOperation = $this->processManagementOperation();
}
if (!$processedManagementOperation && !$this->isBrokerConnected()) {
$this->processCommandQueue();
if (!$processedManagementOperation && $this->shouldPollApiCommandQueue($brokerConnected)) {
$this->processCommandQueue($brokerConnected ? 0 : null);
}
$this->pumpBrokerTransport();
$this->flushOutbox();
@@ -1093,7 +1111,7 @@ final class TruckwashEdgeAgent
return;
}
$installedVersion = (string)$this->config->get('installedVersion', 'compose-php-agent-v2');
$installedVersion = (string)$this->config->get('installedVersion', 'compose-php-agent-v3');
try {
$response = $this->http->post('/edge-agent/claim', [
'token' => (string)$this->config->get('installToken'),
@@ -1249,10 +1267,10 @@ final class TruckwashEdgeAgent
'agent_token' => (string)$this->config->get('agentToken'),
'status' => 'ONLINE',
'hostname' => gethostname() ?: 'truckwash-edge',
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v2'),
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v3'),
'target_version' => (string)$this->config->get(
'targetVersion',
$this->config->get('installedVersion', 'compose-php-agent-v2')
$this->config->get('installedVersion', 'compose-php-agent-v3')
),
'metadata' => array_merge([
'agent_instance_id' => $this->agentInstanceId,
@@ -1356,7 +1374,8 @@ final class TruckwashEdgeAgent
$this->sendControlPlaneEvent(
'/edge-agent/gateways/' . $gatewayId . '/selfserve/machine-signal',
$payload,
'machine_signal'
'machine_signal',
self::MACHINE_SIGNAL_TIMEOUT_SECONDS
);
}
@@ -1464,14 +1483,29 @@ final class TruckwashEdgeAgent
return null;
}
private function processCommandQueue(): void
private function shouldPollApiCommandQueue(bool $brokerConnected): bool
{
if (!$brokerConnected) {
return true;
}
$now = time();
if (($now - $this->lastBrokerConnectedCommandPollAt) < self::BROKER_CONNECTED_COMMAND_POLL_INTERVAL_SECONDS) {
return false;
}
$this->lastBrokerConnectedCommandPollAt = $now;
return true;
}
private function processCommandQueue(?int $waitSecondsOverride = null): void
{
$gatewayId = (int)$this->config->get('gatewayId');
if ($gatewayId <= 0) {
return;
}
$waitSeconds = (int)$this->config->get('operationPollTimeoutSeconds', 20);
$waitSeconds = $waitSecondsOverride ?? (int)$this->config->get('operationPollTimeoutSeconds', 20);
try {
$response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/poll', [
'agent_token' => (string)$this->config->get('agentToken'),
@@ -1948,7 +1982,7 @@ final class TruckwashEdgeAgent
return [
'applied' => false,
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v2'),
'installed_version' => (string)$this->config->get('installedVersion', 'compose-php-agent-v3'),
'staged_version' => $targetVersion,
'target_version' => $targetVersion,
'staged_at' => $stagedAt,
@@ -2010,26 +2044,156 @@ final class TruckwashEdgeAgent
$localIp = (string)($request['localIp'] ?? $request['local_ip'] ?? '');
$channel = (int)($request['channel'] ?? 0);
$on = (bool)($request['on'] ?? false);
$toggleAfter = $this->resolveRelayToggleAfterSeconds($request);
$deviceGeneration = $this->resolveShellyCommandGeneration($request);
$workerPayload = [
'local_ip' => $localIp,
'channel' => $channel,
'on' => $on,
];
if ($toggleAfter !== null) {
$workerPayload['toggle_after'] = $toggleAfter;
$workerPayload['toggleAfter'] = $toggleAfter;
}
if ($deviceGeneration !== null) {
$workerPayload['device_generation'] = $deviceGeneration;
$workerPayload['deviceGeneration'] = $deviceGeneration;
}
try {
return $this->workerHttp->post('/relay/switch', [
'local_ip' => $localIp,
'channel' => $channel,
'on' => $on,
], 8) ?? [];
return $this->workerHttp->post('/relay/switch', $workerPayload, 8) ?? [];
} catch (Throwable) {
$rpcUrl = sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s', $localIp, $channel, $on ? 'true' : 'false');
$timerQuery = $toggleAfter === null ? '' : '&toggle_after=' . rawurlencode((string)$toggleAfter);
$legacyTimerQuery = $toggleAfter === null ? '' : '&timer=' . rawurlencode((string)$toggleAfter);
$runRpcSwitch = fn(): array => $this->http->getJson(
sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s%s', $localIp, $channel, $on ? 'true' : 'false', $timerQuery),
8
);
$runLegacySwitch = fn(): array => $this->http->getJson(
sprintf('http://%s/relay/%d?turn=%s%s', $localIp, $channel, $on ? 'on' : 'off', $legacyTimerQuery),
8
);
if ($toggleAfter !== null) {
$attempts = $deviceGeneration === 1
? [$runLegacySwitch, $runRpcSwitch]
: [$runRpcSwitch, $runLegacySwitch];
$lastError = null;
foreach ($attempts as $attempt) {
try {
$attempt();
return $this->fetchShellyState($localIp, $channel);
} catch (Throwable $throwable) {
$lastError = $throwable;
}
}
throw $lastError ?? new RuntimeException('Unable to switch relay');
}
try {
$this->http->getJson($rpcUrl, 8);
$runRpcSwitch();
} catch (Throwable) {
$legacyUrl = sprintf('http://%s/relay/%d?turn=%s', $localIp, $channel, $on ? 'on' : 'off');
$this->http->getJson($legacyUrl, 8);
$runLegacySwitch();
}
return $this->fetchShellyState($localIp, $channel);
}
}
private function resolveRelayToggleAfterSeconds(array $request): ?int
{
$configured = $request['toggleAfter'] ?? $request['toggle_after'] ?? $request['timer'] ?? null;
if (!is_int($configured) && !is_float($configured) && !(is_string($configured) && is_numeric(trim($configured)))) {
return null;
}
$seconds = (int)floor((float)$configured);
if ($seconds <= 0) {
return null;
}
return min($seconds, self::MAX_RELAY_TOGGLE_AFTER_SECONDS);
}
private function normalizeShellyDeviceGeneration(mixed $value): ?int
{
if (is_int($value) || is_float($value) || (is_string($value) && is_numeric(trim($value)))) {
$generation = (int)$value;
return $generation > 0 ? $generation : null;
}
return null;
}
private function inferShellyDeviceGenerationFromString(mixed $value): ?int
{
$normalized = trim((string)$value);
if ($normalized === '') {
return null;
}
if (preg_match('/\bgen(?:eration)?\s*([1-9]\d*)\b/i', $normalized, $explicit) === 1) {
return (int)$explicit[1];
}
$upper = strtoupper($normalized);
if (preg_match('/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/', $upper, $series) === 1) {
return (int)$series[1];
}
if (preg_match('/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i', $normalized) === 1 || preg_match('/\bSP[A-Z0-9]+-[A-Z0-9-]+\b/', $upper) === 1) {
return 2;
}
if (preg_match('/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/', $upper) === 1) {
return 1;
}
return null;
}
private function resolveShellyCommandGeneration(array $request): ?int
{
foreach ([
$request['gen'] ?? null,
$request['generation'] ?? null,
$request['deviceGeneration'] ?? null,
$request['device_generation'] ?? null,
is_array($request['capabilities'] ?? null) ? ($request['capabilities']['generation'] ?? null) : null,
] as $candidate) {
$generation = $this->normalizeShellyDeviceGeneration($candidate);
if ($generation !== null) {
return $generation;
}
}
foreach ([
$request['model'] ?? null,
$request['deviceModel'] ?? null,
$request['device_model'] ?? null,
$request['type'] ?? null,
$request['deviceType'] ?? null,
$request['device_type'] ?? null,
$request['app'] ?? null,
$request['name'] ?? null,
$request['deviceName'] ?? null,
$request['device_name'] ?? null,
$request['deviceId'] ?? null,
$request['device_id'] ?? null,
$request['relayId'] ?? null,
$request['relay_id'] ?? null,
$request['mac'] ?? null,
] as $candidate) {
$generation = $this->inferShellyDeviceGenerationFromString($candidate);
if ($generation !== null) {
return $generation;
}
}
return null;
}
private function downloadToFileAtomic(string $url, string $path, string $expectedSha256 = ''): array
{
$directory = dirname($path);
@@ -2321,12 +2485,14 @@ final class TruckwashEdgeAgent
private function flushOutbox(): void
{
$items = $this->stateStore->queuedItems(25);
if ($this->shouldSkipOutboxReplay()) {
return;
}
$items = $this->stateStore->queuedItems(self::OUTBOX_REPLAY_BATCH_LIMIT);
foreach ($items as $item) {
try {
$timeoutSeconds = (string)($item['type'] ?? '') === 'operation_complete'
? self::OPERATION_COMPLETE_TIMEOUT_SECONDS
: 20;
$timeoutSeconds = $this->outboxReplayTimeoutSeconds((string)($item['type'] ?? ''));
$endpoint = (string)$item['endpoint'];
$payload = is_array($item['payload'] ?? null) ? (array)$item['payload'] : [];
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
@@ -2334,8 +2500,10 @@ final class TruckwashEdgeAgent
$this->http->post($endpoint, $payload, $timeoutSeconds);
}
$this->stateStore->removeOutboxItem((int)$item['id']);
$this->lastOutboxFailureAt = 0;
$this->recordSuccessfulSync();
} catch (Throwable $throwable) {
$this->lastOutboxFailureAt = time();
$this->recordTransportFailure(
'Outbox replay blocked on ' . (string)$item['type'] . ' for ' . (string)$item['endpoint'],
$throwable
@@ -2345,6 +2513,19 @@ final class TruckwashEdgeAgent
}
}
private function shouldSkipOutboxReplay(): bool
{
return $this->lastOutboxFailureAt > 0
&& (time() - $this->lastOutboxFailureAt) < self::OUTBOX_REPLAY_FAILURE_COOLDOWN_SECONDS;
}
private function outboxReplayTimeoutSeconds(string $type): int
{
return $type === 'operation_complete'
? self::OUTBOX_OPERATION_COMPLETE_REPLAY_TIMEOUT_SECONDS
: self::OUTBOX_REPLAY_TIMEOUT_SECONDS;
}
private function probeWorkerHealth(): array
{
try {
@@ -2433,7 +2614,7 @@ final class TruckwashEdgeAgent
];
}
private function sendControlPlaneEvent(string $endpoint, array $payload, string $type): bool
private function sendControlPlaneEvent(string $endpoint, array $payload, string $type, int $timeoutSeconds = 20): bool
{
$this->lastControlPlaneResponse = null;
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
@@ -2442,7 +2623,7 @@ final class TruckwashEdgeAgent
}
try {
$response = $this->http->post($endpoint, $payload, 20);
$response = $this->http->post($endpoint, $payload, $timeoutSeconds);
$this->lastControlPlaneResponse = is_array($response) ? $response : null;
$this->recordSuccessfulSync();
return true;
@@ -2477,7 +2658,9 @@ final class TruckwashEdgeAgent
}
$current = trim((string)$this->config->get('brokerUrl'));
if (rtrim($current, '/') === rtrim($brokerUrl, '/')) {
$currentNormalized = $this->normalizeBrokerUrlForComparison($current);
$nextNormalized = $this->normalizeBrokerUrlForComparison($brokerUrl);
if ($currentNormalized !== null && $currentNormalized === $nextNormalized) {
return;
}
@@ -2487,6 +2670,38 @@ final class TruckwashEdgeAgent
$this->logger->info('Updated broker URL from control plane heartbeat response.');
}
private function normalizeBrokerUrlForComparison(?string $value): ?string
{
$trimmed = rtrim(trim((string)($value ?? '')), '/');
if ($trimmed === '') {
return null;
}
if (str_starts_with($trimmed, 'https://')) {
$trimmed = 'wss://' . substr($trimmed, 8);
} elseif (str_starts_with($trimmed, 'http://')) {
$trimmed = 'ws://' . substr($trimmed, 7);
} elseif (!str_starts_with($trimmed, 'ws://') && !str_starts_with($trimmed, 'wss://')) {
$trimmed = 'ws://' . $trimmed;
}
$parts = parse_url($trimmed);
if (!is_array($parts) || empty($parts['host'])) {
return strtolower($trimmed);
}
$scheme = strtolower((string)($parts['scheme'] ?? 'ws'));
$host = strtolower((string)$parts['host']);
$port = isset($parts['port']) ? (int)$parts['port'] : ($scheme === 'wss' ? 443 : 80);
$path = rtrim((string)($parts['path'] ?? ''), '/');
if ($host === 'edge-broker' && ($path === '/edge-broker' || str_starts_with($path, '/edge-broker/'))) {
$path = rtrim(substr($path, strlen('/edge-broker')), '/');
}
$isDefaultPort = ($scheme === 'wss' && $port === 443) || ($scheme !== 'wss' && $port === 80);
return $scheme . '://' . $host . ($isDefaultPort ? '' : ':' . $port) . ($path === '' ? '' : $path);
}
private function shouldPersistControlPlaneEventOverHttp(string $endpoint): bool
{
return preg_match('#/edge-agent/gateways/\d+/heartbeat$#', $endpoint) === 1;
@@ -2796,6 +3011,13 @@ final class TruckwashEdgeAgent
]);
}
if (preg_match('#/edge-agent/gateways/\d+/selfserve/machine-signal$#', $endpoint)) {
return $this->sendBrokerMessage([
'type' => 'MACHINE_SIGNAL',
'payload' => $this->stripAgentAuthentication($payload),
]);
}
return null;
}
@@ -2890,7 +3112,10 @@ final class TruckwashEdgeAgent
{
$reloaded = AgentConfig::load($this->config->path);
$this->config = $reloaded;
$this->workerHttp = new HttpJsonClient((string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL));
$this->workerHttp = new HttpJsonClient(
(string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL),
$this->workerAuthorizationHeaders()
);
$this->configureBrokerClient();
}
@@ -95,7 +95,7 @@ services:
test:
[
"CMD-SHELL",
"php -r '$$path=\"/opt/truckwash-edge-agent/runtime/control-plane-status.json\"; if (!is_file($$path)) { exit(1); } $$data=json_decode((string)file_get_contents($$path), true); if (!is_array($$data)) { exit(1); } $$loopAt=strtotime((string)($$data[\"last_loop_at\"] ?? \"\")); $$syncAt=strtotime((string)($$data[\"last_successful_sync_at\"] ?? $$data[\"started_at\"] ?? \"\")); if ($$loopAt === false || $$syncAt === false) { exit(1); } $$now=time(); exit((($$now - $$loopAt) <= 30 && ($$now - $$syncAt) <= 90) ? 0 : 1);'",
"php -r '$$path=\"/opt/truckwash-edge-agent/runtime/control-plane-status.json\"; if (!is_file($$path)) { exit(1); } $$data=json_decode((string)file_get_contents($$path), true); if (!is_array($$data)) { exit(1); } $$loopAt=strtotime((string)($$data[\"last_loop_at\"] ?? $$data[\"started_at\"] ?? \"\")); if ($$loopAt === false) { exit(1); } $$now=time(); exit(($$now - $$loopAt) <= 120 ? 0 : 1);'",
]
interval: 30s
timeout: 5s
@@ -31,14 +31,17 @@ compose_cmd() {
}
print_compose_diagnostics() {
log "docker ps --format '{{.Names}} {{.Status}}'"
docker ps --format '{{.Names}} {{.Status}}' || true
local compose_project_name
compose_project_name="$(config_value composeProjectName 'truckwash-edge-gateway')"
log "compose ps"
compose_cmd -f "$COMPOSE_FILE" ps || true
log "docker ps -a --format '{{.Names}} {{.Status}} {{.Ports}}'"
docker ps -a --format '{{.Names}} {{.Status}} {{.Ports}}' || true
log "compose logs --tail=80"
compose_cmd -f "$COMPOSE_FILE" logs --tail=80 || true
log "compose ps -a --no-trunc"
COMPOSE_PROJECT_NAME="$compose_project_name" compose_cmd -f "$COMPOSE_FILE" ps -a --no-trunc || true
log "compose logs --no-color --no-log-prefix --tail=120"
COMPOSE_PROJECT_NAME="$compose_project_name" compose_cmd -f "$COMPOSE_FILE" logs --no-color --no-log-prefix --tail=120 || true
}
config_value() {
@@ -61,6 +64,32 @@ config_value() {
' "$CONFIG_PATH" "$key" "$fallback"
}
staged_update_value() {
local key="$1"
local fallback="${2:-}"
php -r '
$path = $argv[1];
$key = $argv[2];
$fallback = $argv[3];
if (!is_file($path)) {
echo $fallback;
exit(0);
}
$decoded = json_decode((string)file_get_contents($path), true);
if (!is_array($decoded) || !array_key_exists($key, $decoded) || $decoded[$key] === null || $decoded[$key] === "") {
echo $fallback;
exit(0);
}
echo is_scalar($decoded[$key]) ? (string)$decoded[$key] : json_encode($decoded[$key], JSON_UNESCAPED_SLASHES);
' "$STAGED_UPDATE_PATH" "$key" "$fallback"
}
staged_update_is_applied() {
local status
status="$(staged_update_value status '')"
[ "$status" = "APPLIED" ]
}
ensure_dirs() {
mkdir -p "$RUNTIME_DIR" "$RUNTIME_DIR/backups"
}
@@ -103,7 +132,10 @@ ensure_stack_env() {
within_update_window() {
local window
window="$(config_value updateWindow '02:00-04:00')"
window="$(staged_update_value update_window '')"
if [ -z "$window" ]; then
window="$(config_value updateWindow '02:00-04:00')"
fi
php -r '
$window = $argv[1];
[$start, $end] = array_pad(explode("-", $window, 2), 2, "");
@@ -283,6 +315,10 @@ case "$ACTION" in
reconcile_stack
;;
reconcile)
if [ -f "$STAGED_UPDATE_PATH" ] && staged_update_is_applied; then
log "Staged update already applied; nothing to reconcile"
exit 0
fi
if [ -f "$STAGED_UPDATE_PATH" ] && ! within_update_window; then
log "Update is staged but outside the maintenance window; keeping current stack running"
exit 0
@@ -2,6 +2,8 @@
declare(strict_types=1);
const WORKER_MAX_RELAY_TOGGLE_AFTER_SECONDS = 5;
function worker_json_response(int $status, array $payload): void
{
http_response_code($status);
@@ -156,17 +158,173 @@ function worker_fetch_shelly_input_state(string $localIp, int $channel): ?array
}
}
function worker_switch_shelly_state(string $localIp, int $channel, bool $on): array
function worker_normalize_shelly_device_generation(mixed $value): ?int
{
if (is_int($value) || is_float($value) || (is_string($value) && is_numeric(trim($value)))) {
$generation = (int)$value;
return $generation > 0 ? $generation : null;
}
return null;
}
function worker_infer_shelly_device_generation_from_string(mixed $value): ?int
{
$normalized = trim((string)$value);
if ($normalized === '') {
return null;
}
if (preg_match('/\bgen(?:eration)?\s*([1-9]\d*)\b/i', $normalized, $explicit) === 1) {
return (int)$explicit[1];
}
$upper = strtoupper($normalized);
if (preg_match('/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/', $upper, $series) === 1) {
return (int)$series[1];
}
if (preg_match('/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i', $normalized) === 1 || preg_match('/\bSP[A-Z0-9]+-[A-Z0-9-]+\b/', $upper) === 1) {
return 2;
}
if (preg_match('/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/', $upper) === 1) {
return 1;
}
return null;
}
function worker_resolve_shelly_command_generation(array $command): ?int
{
foreach ([
$command['gen'] ?? null,
$command['generation'] ?? null,
$command['deviceGeneration'] ?? null,
$command['device_generation'] ?? null,
is_array($command['capabilities'] ?? null) ? ($command['capabilities']['generation'] ?? null) : null,
] as $candidate) {
$generation = worker_normalize_shelly_device_generation($candidate);
if ($generation !== null) {
return $generation;
}
}
foreach ([
$command['model'] ?? null,
$command['deviceModel'] ?? null,
$command['device_model'] ?? null,
$command['type'] ?? null,
$command['deviceType'] ?? null,
$command['device_type'] ?? null,
$command['app'] ?? null,
$command['name'] ?? null,
$command['deviceName'] ?? null,
$command['device_name'] ?? null,
$command['deviceId'] ?? null,
$command['device_id'] ?? null,
$command['relayId'] ?? null,
$command['relay_id'] ?? null,
$command['mac'] ?? null,
] as $candidate) {
$generation = worker_infer_shelly_device_generation_from_string($candidate);
if ($generation !== null) {
return $generation;
}
}
return null;
}
function worker_resolve_relay_toggle_after_seconds(array $command): ?int
{
$configured = $command['toggleAfter'] ?? $command['toggle_after'] ?? $command['timer'] ?? null;
if (!is_int($configured) && !is_float($configured) && !(is_string($configured) && is_numeric(trim($configured)))) {
return null;
}
$seconds = (int)floor((float)$configured);
if ($seconds <= 0) {
return null;
}
return min($seconds, WORKER_MAX_RELAY_TOGGLE_AFTER_SECONDS);
}
function worker_switch_shelly_state(string $localIp, int $channel, bool $on, array $command = []): array
{
if ($localIp === '') {
throw new RuntimeException('Missing Shelly IP address');
}
$toggleAfter = worker_resolve_relay_toggle_after_seconds($command);
$timerQuery = $toggleAfter === null ? '' : '&toggle_after=' . rawurlencode((string)$toggleAfter);
$legacyTimerQuery = $toggleAfter === null ? '' : '&timer=' . rawurlencode((string)$toggleAfter);
$runRpcSwitch = static fn(): array => worker_http_get_json(
sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s%s', $localIp, $channel, $on ? 'true' : 'false', $timerQuery)
);
$runLegacySwitch = static fn(): array => worker_http_get_json(
sprintf('http://%s/relay/%d?turn=%s%s', $localIp, $channel, $on ? 'on' : 'off', $legacyTimerQuery)
);
if ($toggleAfter !== null) {
$attempts = worker_resolve_shelly_command_generation($command) === 1
? [$runLegacySwitch, $runRpcSwitch]
: [$runRpcSwitch, $runLegacySwitch];
$lastError = null;
foreach ($attempts as $attempt) {
try {
$attempt();
return worker_fetch_shelly_state($localIp, $channel);
} catch (Throwable $throwable) {
$lastError = $throwable;
}
}
throw $lastError ?? new RuntimeException('Unable to switch relay');
}
try {
worker_http_get_json(sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s', $localIp, $channel, $on ? 'true' : 'false'));
$runRpcSwitch();
} catch (Throwable) {
worker_http_get_json(sprintf('http://%s/relay/%d?turn=%s', $localIp, $channel, $on ? 'on' : 'off'));
$runLegacySwitch();
}
return worker_fetch_shelly_state($localIp, $channel);
}
function worker_normalize_relay_commands(array $body): array
{
$commands = $body['commands'] ?? [];
return is_array($commands) ? array_values($commands) : [];
}
function worker_relay_command_result(array $command, callable $handler): array
{
$target = trim((string)($command['target'] ?? $command['relay'] ?? ''));
$relayId = trim((string)($command['relayId'] ?? $command['relay_id'] ?? ''));
try {
$localIp = trim((string)($command['local_ip'] ?? $command['localIp'] ?? ''));
$channel = (int)($command['channel'] ?? 0);
return [
'target' => $target,
'relayId' => $relayId,
'relay_id' => $relayId,
'ok' => true,
'payload' => $handler($localIp, $channel, $command),
];
} catch (Throwable $throwable) {
return [
'target' => $target,
'relayId' => $relayId,
'relay_id' => $relayId,
'ok' => false,
'error' => $throwable->getMessage(),
];
}
}
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$path = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?? '/');
$body = worker_read_json_body();
@@ -244,7 +402,50 @@ try {
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
$channel = (int)($body['channel'] ?? 0);
$on = (bool)($body['on'] ?? false);
worker_json_response(200, worker_switch_shelly_state($localIp, $channel, $on));
worker_json_response(200, worker_switch_shelly_state($localIp, $channel, $on, $body));
return;
}
if ($method === 'POST' && $path === '/relay/batch-status') {
if (!worker_require_authorization()) {
return;
}
$results = array_map(
fn(array $command): array => worker_relay_command_result(
$command,
fn(string $localIp, int $channel): array => worker_fetch_shelly_state($localIp, $channel)
),
worker_normalize_relay_commands($body)
);
worker_json_response(200, [
'batch_id' => $body['batch_id'] ?? $body['batchId'] ?? null,
'results' => $results,
]);
return;
}
if ($method === 'POST' && $path === '/relay/batch-switch') {
if (!worker_require_authorization()) {
return;
}
$results = array_map(
fn(array $command): array => worker_relay_command_result(
$command,
fn(string $localIp, int $channel, array $entry): array => worker_switch_shelly_state(
$localIp,
$channel,
(bool)($entry['on'] ?? false),
$entry
)
),
worker_normalize_relay_commands($body)
);
worker_json_response(200, [
'batch_id' => $body['batch_id'] ?? $body['batchId'] ?? null,
'results' => $results,
]);
return;
}
@@ -1730,12 +1730,16 @@ class InvoicingPeriodRoute
$product_cache[$product_id] = (new products_o())->select($product_id);
}
if (!isset($department_price_cache[$department_id][$product_id])) {
$department_price_cache[$department_id][$product_id] = (int)$product_cache[$product_id]->getDepartmentPrice($department_id);
$department_price_cache[$department_id][$product_id] = $product_cache[$product_id]->getDepartmentPriceResolution($department_id);
}
if (!array_key_exists($product_id, $discount_cache)) {
$discount_cache[$product_id] = $user->getCustomPrice($product_id, false);
$unit_price = (int)$department_price_cache[$department_id][$product_id]['price'];
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$department_id][$product_id])) {
$unit_price = $user->applyProductCustomerPricing($product_id, $unit_price, false);
}
$discount_cache[$product_id] = $unit_price;
}
$post_discount = (int)round($department_price_cache[$department_id][$product_id] * (1 - ($discount_cache[$product_id] / 100))) * $quantity;
$post_discount = (int)$discount_cache[$product_id] * $quantity;
$transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + $post_discount);
}
+134 -28
View File
@@ -24,6 +24,72 @@ class authRoute
{
use route_t;
private function passkeyChallengePrincipalCacheKey(string $challengeToken): string
{
return 'passkey_challenge_principal:' . $challengeToken;
}
private function setPasskeyChallengePrincipal(string $challengeToken, string $principalType): void
{
if (!defined('redis')) {
return;
}
constant('redis')->setEx($this->passkeyChallengePrincipalCacheKey($challengeToken), $principalType, 5 * 60);
}
private function getPasskeyChallengePrincipal(string $challengeToken): string
{
if (!defined('redis')) {
return 'discoverable';
}
$principalType = constant('redis')->get($this->passkeyChallengePrincipalCacheKey($challengeToken));
return is_string($principalType) && in_array($principalType, ['user', 'subuser'], true)
? $principalType
: 'discoverable';
}
private function clearPasskeyChallengePrincipal(string $challengeToken): void
{
if (!defined('redis')) {
return;
}
constant('redis')->delete($this->passkeyChallengePrincipalCacheKey($challengeToken));
}
private function passkeyAllowCredentials(int $userId, bool $isSubuser): array
{
if ($userId <= 0) {
return [];
}
$passkeys = new passkeys_o();
$passkeys->setAdditionalWhereClause(
'`user_id` = ' . (int)$userId . ' AND `is_subuser` = ' . ($isSubuser ? '1' : '0')
);
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
$transports = null;
if (isset($o['transports'])) {
$decoded = json_decode($o['transports'], true);
$transports = is_array($decoded) ? $decoded : null;
}
return [
'type' => 'public-key',
'id' => $o['credential_id'] ?? null,
'transports' => $transports,
];
});
if (isset($list['items']) && is_array($list['items'])) {
$list = $list['items'];
}
return is_array($list)
? array_values(array_filter($list, function ($item) {
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
}))
: [];
}
public function run(): void
{
$this->post('/auth/login', function () {
@@ -361,6 +427,7 @@ class authRoute
$contactEmail = self::getParameter('contactEmail');
$contactPhone = (int)self::getParameter('contactPhone');
$contactName = self::getParameter('contactName');
$ean = null;
/**
* Validate
*/
@@ -388,6 +455,13 @@ class authRoute
self::requireMinValue($contactPhone, 10000000);
self::requireMaxValue($contactPhone, 9999999999);
}
if (self::isParametersSet(['ean'])) {
try {
$ean = economic::normalizeCustomerEan(self::getParameter('ean'));
} catch (\InvalidArgumentException $exception) {
$response->error($exception->getMessage(), 400);
}
}
/**
* If the contact phone is empty, default to company phone
@@ -479,6 +553,7 @@ class authRoute
(int)$companyPhone,
(int)$contactPhone,
$companyInformation,
$ean,
);
} catch (Exception $exception) {
$recoveredCustomer = $this->recoverRegistrationAfterCreateFailure(
@@ -625,11 +700,53 @@ class authRoute
global $response;
$this->requireRecaptcha();
$principal_type = strtolower(trim((string)(self::getParameter('principal_type') ?? self::getParameter('user_type') ?? '')));
if ($principal_type === '') {
$principal_type = self::getParameter('customer_number') !== null ? 'user' : 'discoverable';
}
if (!in_array($principal_type, ['user', 'subuser', 'discoverable'], true)) {
$response->error('Invalid principal_type', 400);
}
$customer_number = self::getParameter('customer_number');
$user_id = 0;
$allowCredentials = [];
if ($customer_number !== null) {
if ($principal_type === 'subuser') {
$subuser = null;
if (self::isParametersSet(['subuser_id'])) {
$subuser_id = (int)self::getParameter('subuser_id');
self::requireType($subuser_id, $this->type_int());
self::requireMinValue($subuser_id, 1);
$candidate = (new subusers_o())->select($subuser_id);
if ($candidate->exists()) {
$candidate->getObjectProperties();
$subuser = $candidate;
}
} elseif (self::isParametersSet(['username'])) {
$username = (string)self::getParameter('username');
self::requireType($username, $this->type_string());
self::requireMinLength('username', 3);
self::requireMaxLength('username', 255);
$subuser = (new subusers_o())->getSubuserByUsername($username);
} elseif (self::isParametersSet(['phone_country_code', 'phone'])) {
$phone_country_code = (int)self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone');
self::requireType($phone_country_code, $this->type_int());
self::requireMinLength('phone_country_code', 1);
self::requireMaxLength('phone_country_code', 3);
self::requireType($phone, $this->type_int());
self::requireMinLength('phone', 4);
self::requireMaxLength('phone', 15);
$subuser = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
}
if ($subuser !== null) {
$user_id = (int)$subuser->id;
$allowCredentials = $this->passkeyAllowCredentials($user_id, true);
}
} elseif ($customer_number !== null) {
$principal_type = 'user';
$customer_number = (int)$customer_number;
self::requireType($customer_number, $this->type_int());
self::requireMinValue($customer_number, 1);
@@ -639,33 +756,7 @@ class authRoute
if ($user->exists()) {
$user_id = (int)$user->id;
// Load passkeys for this user (non-subuser)
$passkeys = new passkeys_o();
$passkeys->setAdditionalWhereClause('`user_id` = ' . (int)$user_id . ' AND `is_subuser` = 0');
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
$transports = null;
if (isset($o['transports'])) {
$decoded = json_decode($o['transports'], true);
$transports = is_array($decoded) ? $decoded : null;
}
return [
'type' => 'public-key',
'id' => $o['credential_id'] ?? null,
'transports' => $transports,
];
});
// Ensure we return a simple array of credentials (without pagination wrapper)
if (isset($list['items']) && is_array($list['items'])) {
$allowCredentials = array_values(array_filter($list['items'], function ($item) {
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
}));
} elseif (is_array($list)) {
$allowCredentials = array_values(array_filter($list, function ($item) {
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
}));
}
$allowCredentials = $this->passkeyAllowCredentials($user_id, false);
}
}
@@ -682,6 +773,10 @@ class authRoute
// Create an ephemeral token to bind the challenge to the (potential) user
(new tokens_o())->create($user_id, $challenge_token, 'PASSKEY_CHALLENGE');
$this->setPasskeyChallengePrincipal(
$challenge_token,
$principal_type === 'subuser' ? 'subuser' : ($principal_type === 'user' ? 'user' : 'discoverable')
);
$logDetails = $customer_number ? 'Issued passkey challenge for customer ' . $customer_number : 'Issued passkey challenge (discoverable)';
(new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_PASSKEY_CHALLENGE', $logDetails);
@@ -733,6 +828,7 @@ class authRoute
if ($token_type !== 'PASSKEY_CHALLENGE') {
$response->error('Invalid token type', 401);
}
$challengePrincipalType = $this->getPasskeyChallengePrincipal($challenge_token);
// Determine rpId/host
$host = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST) ?: ($_SERVER['SERVER_NAME'] ?? 'localhost');
@@ -768,7 +864,17 @@ class authRoute
// Success → issue session token accordingly and delete the challenge token
$issued_to_user_id = (int)$passkey->user_id->value();
$is_subuser = (bool)$passkey->is_subuser->value();
if (
($challengePrincipalType === 'subuser' && !$is_subuser)
|| ($challengePrincipalType === 'user' && $is_subuser)
) {
(new logs_o())->add('auth', 'global', 1, $user_id_hint, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Credential principal mismatch');
$token_o->delete($challenge_token);
$this->clearPasskeyChallengePrincipal($challenge_token);
$response->error('Invalid credential', 401);
}
$token_o->delete($challenge_token);
$this->clearPasskeyChallengePrincipal($challenge_token);
(new logs_o())->add('auth', 'global', 1, $issued_to_user_id, 'AUTH_PASSKEY_VERIFY_SUCCESS', 'Passkey assertion accepted');
@@ -13,6 +13,7 @@ use DateTimeZone;
use Exception;
use objects\department_daily_report_complaints_o;
use objects\department_daily_reports_o;
use objects\department_variables_o;
use objects\departments_o;
use objects\logs_o;
use objects\users_o;
@@ -22,6 +23,8 @@ class departmentDailyReportsRoute
{
use route_t;
private const SET_PRODUCT_TARGET_PERMISSION = 'set_department_daily_report_product_targets';
public function run(): void
{
$this->get('/departments/daily-reports', function () {
@@ -812,6 +815,54 @@ class departmentDailyReportsRoute
]
);
$this->get('/superuser/departments/{id}/overview', function () {
global $response;
$this->requirePermission('superuser_fetch_department');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_DEPARTMENT_OVERVIEW', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
$department_id_param = (string)($this->fromRoute('id') ?? '');
if (!ctype_digit($department_id_param) || (int)$department_id_param <= 0) {
$response->error('Parameter id must be a positive integer', 400);
return;
}
self::requireParameters([
'date',
]);
self::validateDateLocally();
$date_to = $this->getDate_to();
$department_id = (int)$department_id_param;
$department = (new departments_o())->select($department_id);
if (!$department->exists()) {
$response->error('Department not found', 404);
return;
}
(new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_DEPARTMENT_OVERVIEW', 'Successfully loaded superuser department overview');
$response->success([
'department' => $department->asArray(['slack_webhook' => false]),
'overview' => $this->buildDailyReportOverview(
[$department_id],
(string)self::getParameter('date'),
$date_to,
self::hasPermission(self::SET_PRODUCT_TARGET_PERMISSION)
),
]);
},
[
'superuser_fetch_department' => 'Get the superuser department overview'
]
);
$this->get('/departments/daily-reports/overview', function () {
global $response;
$this->requirePermission('list_department_daily_reports');
@@ -848,7 +899,8 @@ class departmentDailyReportsRoute
$this->buildDailyReportOverview(
$department_ids,
(string)self::getParameter('date'),
$date_to
$date_to,
self::hasPermission(self::SET_PRODUCT_TARGET_PERMISSION)
)
);
},
@@ -859,6 +911,73 @@ class departmentDailyReportsRoute
]
);
$this->put('/departments/daily-reports/product-targets', function () {
global $response;
$this->requirePermission(self::SET_PRODUCT_TARGET_PERMISSION);
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'SET_DEPARTMENT_DAILY_REPORT_PRODUCT_TARGET', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
self::requireParameters([
'department_id',
'product_id',
'target_percentage',
]);
$department_id = (int)self::getParameter('department_id');
if ($department_id <= 0) {
$response->error('Parameter department_id must be a positive integer', 400);
return;
}
$department = (new departments_o())->select($department_id);
if (!$department->exists()) {
$response->error('Department not found', 404);
return;
}
self::requireDepartmentAccess($department_id);
$product_id = (int)self::getParameter('product_id');
if (!$this->isDailyReportProductId($product_id)) {
$response->error('Invalid daily report product_id', 400);
return;
}
$parsed_target = $this->parseDailyReportProductTargetPercentage(self::getParameter('target_percentage'));
if (!$parsed_target['valid']) {
$response->error($parsed_target['message'], 400);
return;
}
$target_percentage = $parsed_target['value'];
$department_variables = (new department_variables_o())->selectDepartment($department_id);
$target_key = $this->dailyReportProductTargetVariableKey($product_id);
if ($target_percentage === null) {
$this->clearDailyReportProductTarget($department_variables, $target_key);
} else {
$department_variables->set($target_key, number_format($target_percentage, 1, '.', ''));
}
(new logs_o())->add('departments', 'global', 1, $user->id, 'SET_DEPARTMENT_DAILY_REPORT_PRODUCT_TARGET', 'Successfully updated department daily report product target');
$response->success([
'department_id' => $department_id,
'product_id' => $product_id,
'target_percentage' => $target_percentage,
]);
},
[
self::SET_PRODUCT_TARGET_PERMISSION => 'Set department daily report product target percentages',
'department_access_:department_id' => 'Access the department'
]
);
$this->get('/departments/daily-reports/product-count', function () {
// Require the user to be logged in
global $response;
@@ -1322,7 +1441,7 @@ class departmentDailyReportsRoute
* }
* @throws Exception
*/
private function buildDailyReportOverview(array $department_ids, string $date, string $date_to): array
private function buildDailyReportOverview(array $department_ids, string $date, string $date_to, bool $include_product_targets = false): array
{
$repository = $this->dailyReportRepository();
$transaction_summary = $repository->getTransactionSummaryForDepartments($date, $department_ids, $date_to);
@@ -1342,6 +1461,11 @@ class departmentDailyReportsRoute
$overtime_metric = $this->buildOvertimeMetric($department_ids, $date, $date_to);
$product_target_lookup = [];
if ($include_product_targets && count($department_ids) === 1) {
$product_target_lookup = $this->getDailyReportProductTargetsForDepartment((int)$department_ids[0], $product_definitions);
}
return $this->assembleDailyReportOverview(
$department_ids,
$date,
@@ -1352,7 +1476,8 @@ class departmentDailyReportsRoute
$product_summary_lookup,
$complaints_metric,
$night_wash_metric,
$overtime_metric
$overtime_metric,
$product_target_lookup
);
}
@@ -1365,6 +1490,7 @@ class departmentDailyReportsRoute
* @param array<string,mixed> $complaints_metric
* @param array<string,mixed> $night_wash_metric
* @param array<string,mixed> $overtime_metric
* @param array<int,float> $product_target_lookup
* @return array{
* department_ids:array<int>,
* date:string,
@@ -1383,7 +1509,8 @@ class departmentDailyReportsRoute
array $product_summary_lookup,
array $complaints_metric,
array $night_wash_metric,
array $overtime_metric
array $overtime_metric,
array $product_target_lookup = []
): array {
$products = [];
foreach ($product_definitions as $definition) {
@@ -1401,6 +1528,12 @@ class departmentDailyReportsRoute
'state' => 'ready',
'value' => (int)($product_summary['quantity'] ?? 0),
'out_of' => (int)($product_summary['out_of'] ?? 0),
'target_percentage' => array_key_exists($product_id, $product_target_lookup)
? (float)$product_target_lookup[$product_id]
: null,
'target_department_id' => array_key_exists($product_id, $product_target_lookup)
? (int)$department_ids[0]
: null,
];
}
@@ -1459,6 +1592,87 @@ class departmentDailyReportsRoute
return array_values($normalized);
}
private function isDailyReportProductId(int $product_id): bool
{
return in_array(
$product_id,
array_map(static fn(array $definition): int => (int)$definition['product_id'], $this->getDailyReportProductDefinitions()),
true
);
}
/**
* @return array{valid:bool,value:?float,message:string}
*/
private function parseDailyReportProductTargetPercentage(mixed $target_percentage): array
{
if ($target_percentage === null) {
return ['valid' => true, 'value' => null, 'message' => ''];
}
if (is_string($target_percentage)) {
$target_percentage = trim($target_percentage);
if ($target_percentage === '') {
return ['valid' => true, 'value' => null, 'message' => ''];
}
}
if (!is_int($target_percentage) && !is_float($target_percentage) && !(is_string($target_percentage) && is_numeric($target_percentage))) {
return ['valid' => false, 'value' => null, 'message' => 'Parameter target_percentage must be numeric, null, or empty'];
}
$target_percentage = round((float)$target_percentage, 1);
if ($target_percentage < 0.0 || $target_percentage > 100.0) {
return ['valid' => false, 'value' => null, 'message' => 'Parameter target_percentage must be between 0 and 100'];
}
return ['valid' => true, 'value' => $target_percentage, 'message' => ''];
}
/**
* @param array<int,array{product_id:int,slug:string,title:string}> $product_definitions
* @return array<int,float>
* @throws Exception
*/
protected function getDailyReportProductTargetsForDepartment(int $department_id, array $product_definitions): array
{
$department_variables = (new department_variables_o())->selectDepartment($department_id);
$targets = [];
foreach ($product_definitions as $definition) {
$product_id = (int)$definition['product_id'];
$stored_target = $department_variables->getVariable($this->dailyReportProductTargetVariableKey($product_id));
if ($stored_target === null || $stored_target === '' || !is_numeric($stored_target)) {
continue;
}
$targets[$product_id] = round((float)$stored_target, 1);
}
return $targets;
}
protected function clearDailyReportProductTarget(department_variables_o $department_variables, string $target_key): void
{
$existing_targets = $department_variables->getFieldsWhere([
'department_id' => $department_variables->department_id,
'variable' => $target_key,
], ['id']);
if (!$existing_targets) {
return;
}
department_variables_o::delete_object('department_variables', (int)$existing_targets[0]['id']);
$department_variables->objectChanged();
}
private function dailyReportProductTargetVariableKey(int $product_id): string
{
return 'daily_report_product_target_percentage_' . $product_id;
}
/**
* @return array<int,array{product_id:int,slug:string,title:string}>
*/
@@ -9,6 +9,7 @@ use classes\authentication;
use classes\response;
use classes\selfserve;
use modules\selfserve\classes\selfserve_wash_flow;
use modules\subusers\helpers\subusers_permission_node_key;
use objects\customer_vehicles_o;
use objects\department_lanes_o;
use objects\department_selfserve_tasks_o;
@@ -118,17 +119,19 @@ class departmentSelfserveVehicleConditionsRoute
*/
$this->get('/department/selfserve/vehicle/allowed', function () {
global $response;
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
[$user, $actor_id] = $this->getAuthenticatedSelfServePrincipal();
$own_permission = self::definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST);
$has_global = $user->hasPermission('list_department_selfserve_vehicle_conditions');
$has_own = $user->hasPermission('list_own_department_selfserve_vehicle_conditions');
$has_global = $user !== null && $this->hasPermission('list_department_selfserve_vehicle_conditions');
$has_own = $this->hasPermission($own_permission);
if (!$has_global && !$has_own) {
$response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']);
}
if ($user === null && !$has_own) {
$response->error('Invalid session', 400);
}
self::requireParameters(['lane_id', 'reg']);
$lane_id = (int)self::getParameter('lane_id');
$reg = selfserve::standardize_registration((string)self::getParameter('reg'));
@@ -147,7 +150,7 @@ class departmentSelfserveVehicleConditionsRoute
]);
}
(new logs_o())->add('department_selfserve_vehicle_conditions', (int)$lane->department->value(), 1, $user->id, 'CHECK_VEHICLE_ALLOWED', 'User checked self-serve eligibility for lane ' . $lane_id . ' and vehicle ' . $reg);
(new logs_o())->add('department_selfserve_vehicle_conditions', (int)$lane->department->value(), 1, $actor_id, 'CHECK_VEHICLE_ALLOWED', 'User checked self-serve eligibility for lane ' . $lane_id . ' and vehicle ' . $reg);
$response->success($flow->previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id));
}, [
'list_department_selfserve_vehicle_conditions' => 'Check whether self-serve is allowed for a specific vehicle',
@@ -159,17 +162,19 @@ class departmentSelfserveVehicleConditionsRoute
*/
$this->get('/department/selfserve/washes/summary', function () {
global $response;
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
[$user] = $this->getAuthenticatedSelfServePrincipal();
$own_permission = self::definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST);
$has_global = $user->hasPermission('list_department_selfserve_vehicle_conditions');
$has_own = $user->hasPermission('list_own_department_selfserve_vehicle_conditions');
$has_global = $user !== null && $this->hasPermission('list_department_selfserve_vehicle_conditions');
$has_own = $this->hasPermission($own_permission);
if (!$has_global && !$has_own) {
$response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']);
}
if ($user === null && !$has_own) {
$response->error('Invalid session', 400);
}
$flow = $this->getWashFlow();
$vehicle_type_id = $this->resolveVehicleTypeIdFromQuery();
try {
@@ -236,18 +241,20 @@ class departmentSelfserveVehicleConditionsRoute
*/
$this->post('/department/selfserve/vehicle/conditions', function () {
global $response;
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
[$user, $actor_id] = $this->getAuthenticatedSelfServePrincipal();
$own_permission = self::definePermission('add_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_ADD);
$has_global = $user->hasPermission('add_department_selfserve_vehicle_conditions');
$has_own = $user->hasPermission('add_own_department_selfserve_vehicle_conditions');
$has_global = $user !== null && $this->hasPermission('add_department_selfserve_vehicle_conditions');
$has_own = $this->hasPermission($own_permission);
if (!$has_global && !$has_own) {
$response->forbidden(['add_department_selfserve_vehicle_conditions', 'add_own_department_selfserve_vehicle_conditions']);
}
if ($user === null && !$has_own) {
$response->error('Invalid session', 400);
}
$department = (int)$response->getRequestParameter('department');
$lane = (int)$response->getRequestParameter('lane');
$reg = selfserve::standardize_registration((string)$response->getRequestParameter('reg'));
@@ -275,7 +282,7 @@ class departmentSelfserveVehicleConditionsRoute
$condition_o = new department_selfserve_vehicle_conditions_o();
$condition_o->add($department, $lane, $reg, $question, $value, $customer_id);
$summary = $this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);
(new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'ADD_VEHICLE_CONDITION', 'User added department self-serve vehicle condition ' . $condition_o->id);
(new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $actor_id, 'ADD_VEHICLE_CONDITION', 'User added department self-serve vehicle condition ' . $condition_o->id);
$response->success([
'condition' => $condition_o->asArray(),
'selfserve' => $summary,
@@ -455,6 +462,24 @@ class departmentSelfserveVehicleConditionsRoute
return new selfserve_wash_flow();
}
private function getAuthenticatedSelfServePrincipal(): array
{
global $response;
$auth = new authentication();
$user = $auth->get_user();
if ($user !== false) {
return [$user, (int)$user->id];
}
$subuser = $auth->get_subuser();
if ($subuser !== false) {
return [null, (int)$subuser->id];
}
$response->error('Invalid session', 400);
}
private function resolveVehicleTypeIdFromQuery(): ?int
{
$rawVehicleType = null;
@@ -549,7 +574,7 @@ class departmentSelfserveVehicleConditionsRoute
}
private function assertLaneAccess(
object $user,
?object $user,
int $laneId,
bool $hasGlobalPermission = true,
bool $hasOwnPermission = false,
@@ -563,7 +588,7 @@ class departmentSelfserveVehicleConditionsRoute
$response->error('Department lane not found', 404);
}
if ($hasGlobalPermission && $this->userHasLaneDepartmentAccess($user, $lane)) {
if ($hasGlobalPermission && $user !== null && $this->userHasLaneDepartmentAccess($user, $lane)) {
return $lane;
}
@@ -604,16 +629,17 @@ class departmentSelfserveVehicleConditionsRoute
}
}
private function summaryBelongsToCustomer(object $user, array $summary): bool
private function summaryBelongsToCustomer(?object $user, array $summary): bool
{
$customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions');
$session_customer_number = $summary['session']['customer_number'] ?? null;
if ($session_customer_number !== null && (int)$session_customer_number === (int)$user->customer_number->value()) {
if ($session_customer_number !== null && (int)$session_customer_number === $customer_number) {
return true;
}
$reg = (string)($summary['session']['reg'] ?? '');
$vehicle_o = (new customer_vehicles_o())->selectByPlate($reg);
return $vehicle_o->exists() && (int)$vehicle_o->customer_id->value() === (int)$user->customer_number->value();
return $vehicle_o->exists() && (int)$vehicle_o->customer_id->value() === $customer_number;
}
private function summaryDepartmentId(array $summary): int
@@ -636,11 +662,15 @@ class departmentSelfserveVehicleConditionsRoute
return in_array($lane_department, $authorized_department_ids, true);
}
private function requireAuthenticatedCustomerNumber(object $user, string $elevatedPermission): int
private function requireAuthenticatedCustomerNumber(?object $user, string $elevatedPermission): int
{
global $response;
$customer_number = (int)$user->customer_number->value();
$customer_number = $this->resolveEffectiveCustomerNumber();
if ($customer_number === null && $user !== null && isset($user->customer_number)) {
$customer_number = (int)$user->customer_number->value();
}
$customer_number = (int)$customer_number;
if ($customer_number <= 0) {
$response->forbidden([$elevatedPermission]);
}
@@ -649,7 +679,7 @@ class departmentSelfserveVehicleConditionsRoute
}
private function assertSummaryAccess(
object $user,
?object $user,
array $summary,
bool $hasGlobalPermission,
bool $hasOwnPermission,
@@ -658,7 +688,7 @@ class departmentSelfserveVehicleConditionsRoute
{
global $response;
if ($hasGlobalPermission && $this->userHasSummaryDepartmentAccess($user, $summary)) {
if ($hasGlobalPermission && $user !== null && $this->userHasSummaryDepartmentAccess($user, $summary)) {
return;
}
+24 -8
View File
@@ -103,6 +103,7 @@ class departmentsRoute
'economic_department_id',
'visible',
'archived',
'custom_pricing_only',
'longitude',
'latitude',
])
@@ -123,6 +124,12 @@ class departmentsRoute
'latitude' => (float)$department['latitude'],
'order_priority' => (int)$department['order_priority'],
];
if (
$user->hasPermission('superuser_fetch_department')
|| $user->hasPermission('edit_department')
) {
$tmp_department['custom_pricing_only'] = (bool)(int)($department['custom_pricing_only'] ?? 0);
}
// If the user has the permission to view the slack webhook, add it to the response
if ($user->hasPermission('view_slack_webhook')) {
$tmp_department['slack_webhook'] = $department['slack_webhook'];
@@ -220,6 +227,9 @@ class departmentsRoute
if (self::isParametersSet(['archived'])) {
$department->archived->set(self::isTruthyBooleanValue(self::getParameter('archived')));
}
if (self::isParametersSet(['custom_pricing_only'])) {
$department->custom_pricing_only->set(self::isTruthyBooleanValue(self::getParameter('custom_pricing_only')));
}
$department->objectChanged();
// Log the incident
(new logs_o())->add('departments', (int)self::getParameter('id'), 1, $user->id, 'EDIT_DEPARTMENT', 'Successfully edited a department');
@@ -240,11 +250,17 @@ class departmentsRoute
$this->get('/departments/categories', function () {
// Require the user to be logged in
global $response;
self::requirePermission('list_department_categories');
// Get the user object
$user = (new authentication())->get_user();
$auth = new authentication();
$user = $auth->get_user();
$subuser = $auth->get_subuser();
// Check if the request was successful
if ($user) {
if ($user || $subuser) {
$isCustomerBookingSession = ($user && self::hasPermission('user')) || $subuser;
if (!$isCustomerBookingSession && !self::hasPermission('list_department_categories')) {
$this->emitForbidden(['list_department_categories']);
}
$responsibleUserId = $user ? (int)$user->id : 0;
// Require the department id
self::requireParameters(['id']);
self::requireType((int)self::getParameter('id'), self::TYPE_INT());
@@ -253,14 +269,14 @@ class departmentsRoute
// Validate the department categories object
if (!$department->exists()) {
// Log the incident
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_CATEGORIES', 'Department categories not found');
(new logs_o())->add('departments', 'global', 1, $responsibleUserId, 'LIST_DEPARTMENT_CATEGORIES', 'Department categories not found');
// Return an error
$response->error('Department categories not found', 400);
}
// Get the department categories
$department_categories = new department_categories_o();
// Log the incident
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_CATEGORIES', 'Successfully listed department categories');
(new logs_o())->add('departments', 'global', 1, $responsibleUserId, 'LIST_DEPARTMENT_CATEGORIES', 'Successfully listed department categories');
// Return the list of department categories
$response->success(
$department_categories
@@ -285,7 +301,7 @@ class departmentsRoute
}
},
[
'list_department_categories' => 'List all department categories'
'list_department_categories' => 'List all department categories. Authenticated customer booking sessions may read this endpoint without the permission.'
]
);
@@ -526,7 +542,7 @@ class departmentsRoute
'results' => $departments_o->sendSlackInternalStatisticNotification(
$week_monday,
$week_sunday,
[1, 2, 3, 4, 5, 6, 7],
[],
// Default value
[
25,
+33 -6
View File
@@ -4,6 +4,7 @@ namespace routes;
use classes\authentication;
use classes\recaptcha;
use classes\selfserve;
use classes\virkdata;
use objects\department_lanes_o;
use objects\departments_o;
@@ -53,31 +54,57 @@ class guestRoute
$this->get('/guest/departments', function () {
global $response;
$departments = (new departments_o());
try {
$self_serve_module_enabled = (bool)(new selfserve())->config->enabled->getVariableValue();
} catch (\Throwable) {
$self_serve_module_enabled = false;
}
// Check if the department lane status is requested to be included
$include_lane_status = false;
if (self::isParametersSet(['include_lanes']) && self::getParameter('include_lanes')) {
$include_lane_status = true;
}
$response->success($departments->listObjectsWithPaginationIfSet(function ($department_array) use ($departments, $include_lane_status) {
$response->success($departments->listObjectsWithPaginationIfSet(function ($department_array) use ($departments, $include_lane_status, $self_serve_module_enabled) {
$additional_data = [];
// If including lane status, fetch it
if ($include_lane_status) {
$department = $departments->select((int)$department_array['id']);
// Include the status of self-serve in the department (disabled, when staffed hours)
$additional_data['self_serve_enabled'] = (bool)$department->getSelfServeEnabled();
$department_self_serve_enabled = (bool)$department->getSelfServeEnabled();
$additional_data['self_serve_enabled'] = $department_self_serve_enabled;
$additional_data['self_serve_module_enabled'] = $self_serve_module_enabled;
$additional_data['lanes'] = array_map(
/**
* @param department_lanes_o $lane
* @return array
*/
function (department_lanes_o $lane) {
function (department_lanes_o $lane) use ($department_self_serve_enabled, $self_serve_module_enabled) {
$status = (string)$lane->getLaneStatus()->name;
$lane_self_serve_enabled = $lane->isSelfServeEnabled();
$self_serve_available = $self_serve_module_enabled
&& $department_self_serve_enabled
&& $lane_self_serve_enabled
&& strtoupper(trim($status)) === 'AVAILABLE';
$self_serve_unavailable_reason = null;
if (!$self_serve_module_enabled) {
$self_serve_unavailable_reason = 'self_serve_module_disabled';
} elseif (!$department_self_serve_enabled) {
$self_serve_unavailable_reason = 'department_self_serve_disabled';
} elseif (!$lane_self_serve_enabled) {
$self_serve_unavailable_reason = 'lane_self_serve_disabled';
} elseif (strtoupper(trim($status)) !== 'AVAILABLE') {
$self_serve_unavailable_reason = 'lane_status_' . strtolower($status);
}
return [
'id' => (int)$lane->id,
'name' => (string)$lane->name->value(),
'status' => (string)$lane->getLaneStatus()->name,
'status' => $status,
'products' => $lane->getSelfServeLaneProducts(),
'selfserve_enabled' => $lane->isSelfServeEnabled(),
'machine_available' => $lane->isSelfServeEnabled() && !empty($lane->relay_machine_id->value()),
'selfserve_enabled' => $lane_self_serve_enabled,
'selfserve_available' => $self_serve_available,
'selfserve_unavailable_reason' => $self_serve_unavailable_reason,
'machine_available' => $self_serve_available && !empty($lane->relay_machine_id->value()),
'dynamic_image_id' => $lane->dynamic_image_id->value() ? (int)$lane->dynamic_image_id->value() : null,
];
}, $department->getLanes());
@@ -0,0 +1,157 @@
<?php
namespace routes;
use classes\authentication;
use classes\limited_backoffice_exception;
use classes\limited_backoffice_service;
use traits\route_t;
class limitedBackofficeRoute
{
use route_t;
public function run(): void
{
$this->get('/limited-backoffice/departments', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
return $service->departmentsForUser($user);
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
]);
$this->get('/limited-backoffice/departments/{departmentId}/prices', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$departmentId = $this->routePositiveInt('departmentId');
return $service->getDepartmentPrices($user, $departmentId);
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
]);
$this->put('/limited-backoffice/departments/{departmentId}/prices', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_PRICES);
$departmentId = $this->routePositiveInt('departmentId');
return $service->updateDepartmentPrices($user, $departmentId, $this->requestPayload());
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_MANAGE_PRICES => 'Manage limited backoffice department prices',
]);
$this->get('/limited-backoffice/roles', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
return $service->rolePresets($user);
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]);
$this->get('/limited-backoffice/employees', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
$includeInactive = strtolower((string)($this->fromQuery('include_inactive') ?? 'false')) === 'true';
return $service->employeesForUser($user, $includeInactive);
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]);
$this->post('/limited-backoffice/employees', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
return $service->createEmployee($user, $this->requestPayload());
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]);
$this->post('/limited-backoffice/employees/{employeeId}/migrate', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission('superuser');
return $service->migrateEmployee($user, $this->routePositiveInt('employeeId'), $this->requestPayload());
});
}, [
'superuser' => 'Migrate existing employees to limited backoffice employees',
]);
$this->post('/limited-backoffice/employees/{employeeId}/login-link', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
return $service->createEmployeeLoginLink($user, $this->routePositiveInt('employeeId'));
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]);
$this->put('/limited-backoffice/employees/{employeeId}', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
return $service->updateEmployee($user, $this->routePositiveInt('employeeId'), $this->requestPayload());
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]);
$this->delete('/limited-backoffice/employees/{employeeId}', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
return $service->deactivateEmployee($user, $this->routePositiveInt('employeeId'));
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]);
}
private function withLimitedBackoffice(callable $callback): void
{
global $response;
try {
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$response->success($callback(new limited_backoffice_service(), $user));
} catch (limited_backoffice_exception $exception) {
$response->error($exception->payload(), $exception->statusCode());
}
}
/**
* @return array<string, mixed>
*/
private function requestPayload(): array
{
$payload = json_decode(file_get_contents('php://input'), true);
return is_array($payload) ? $payload : [];
}
private function routePositiveInt(string $name): int
{
$value = $this->fromRoute($name);
if (!is_string($value) || !ctype_digit($value) || (int)$value <= 0) {
throw new limited_backoffice_exception('Invalid route parameter.', 400);
}
return (int)$value;
}
}
@@ -250,6 +250,89 @@ class moduleConfigRoute
]
);
/** Slack internal department goal progress config > TEST */
$this->post('/slack/config/internal-department-goal-progress/test', function () {
global $response;
$this->requirePermission('slack_config');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG_TEST', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$result = (new slack())->test_internal_department_goal_progress_webhook();
if (($result['configured'] ?? false) !== true) {
(new logs_o())->add('slack_config', 'global', 0, $user->id, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG_TEST', 'Slack internal department goal progress webhook URL is not configured');
$response->error($result['message'] ?? 'Slack internal department goal progress webhook URL is not configured.', 400);
}
if (($result['sent'] ?? false) !== true) {
(new logs_o())->add('slack_config', 'global', 0, $user->id, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG_TEST', 'Slack internal department goal progress test webhook failed');
$response->error($result['message'] ?? 'Slack test message failed.', 502);
}
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG_TEST', 'Successfully tested Slack internal department goal progress webhook');
$response->success($result);
},
[
'slack_config' => 'Test Slack internal department goal progress config'
]
);
/** Slack internal department goal progress config > GET */
$this->get('/slack/config/internal-department-goal-progress', function () {
global $response;
$this->requirePermission('slack_config');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG', 'Successfully fetched Slack internal department goal progress config');
$response->success(
(new slack())->get_internal_department_goal_progress_config()
);
},
[
'slack_config' => 'Get Slack internal department goal progress config'
]
);
/** Slack internal department goal progress config > POST */
$this->post('/slack/config/internal-department-goal-progress', function () {
global $response;
$this->requirePermission('slack_config');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$webhook_url = $response->getRequestParameter('internal_department_goal_progress_webhook_url')
?? $response->getRequestParameter('webhook_url')
?? '';
$department_ids = $response->getRequestParameter('internal_department_ids')
?? $response->getRequestParameter('department_ids')
?? [];
if (!is_array($department_ids)) {
$department_ids = array_filter(array_map('trim', explode(',', (string)$department_ids)));
}
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_INTERNAL_DEPARTMENT_GOAL_PROGRESS_CONFIG', 'Successfully updated Slack internal department goal progress config');
$response->success(
(new slack())->set_internal_department_goal_progress_config(
(string)$webhook_url,
$department_ids
)
);
},
[
'slack_config' => 'Update Slack internal department goal progress config'
]
);
$this->get('/backups/config', function () {
global $response;
$this->requirePermission('backups_config');
@@ -60,6 +60,14 @@ class moduleEconomicCustomerRoute
self::requireMaxLength('phone', 255);
self::requireMinLength('name', 1);
self::requireMaxLength('name', 255);
$ean = null;
if (self::isParametersSet(['ean'])) {
try {
$ean = economic::normalizeCustomerEan(self::getParameter('ean'));
} catch (\InvalidArgumentException $exception) {
$response->error($exception->getMessage(), 400);
}
}
(new logs_o())->add('modules_economic', 'global', 1, 0, 'MODULES_ECONOMIC', 'User accessed the customer');
$result = (new economic())->createCustomer(
(int)self::getParameter('customer_number'),
@@ -67,6 +75,9 @@ class moduleEconomicCustomerRoute
(int)self::getParameter('cvr'),
(string)self::getParameter('email'),
(int)self::getParameter('phone'),
null,
null,
$ean,
);
$response->success((object)$result);
} else {
@@ -76,4 +87,4 @@ class moduleEconomicCustomerRoute
});
}
}
}
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\edge_gateway_manager;
use classes\email;
use classes\response;
use classes\router;
@@ -14,11 +15,14 @@ use modules\selfserve\classes\selfserve_wash_flow;
use modules\selfserve\helpers\selfserve_lane_command;
use modules\selfserve\helpers\selfserve_lane_port;
use modules\selfserve\helpers\selfserve_lane_relay;
use modules\selfserve\helpers\selfserve_lane_services;
use modules\selfserve\helpers\selfserve_lane_state;
use modules\selfserve\helpers\selfserve_lane_status;
use modules\selfserve\helpers\selfserve_wash_session_status;
use modules\subusers\helpers\subusers_permission_node_key;
use objects\department_lanes_o;
use objects\departments_o;
use objects\edge_gateway_relay_bindings_o;
use objects\logs_o;
use objects\orders_o;
use objects\customer_vehicles_o;
@@ -715,6 +719,12 @@ class moduleSelfServeRoute
// Merge services (if any)
$merge_services((array)$t->services->value());
}
if (!$this->isMachineWashEnabled()) {
$allowed_services = array_values(array_filter(
$allowed_services,
static fn(string $service): bool => $service !== selfserve_lane_services::MACHINE->name
));
}
// Persist on lane cache (overwrites previous allowed services)
try {
$relay_sync = $lane->setAllowedServicesFromVisibleTasks($allowed_services);
@@ -755,15 +765,23 @@ class moduleSelfServeRoute
$lane = $selfserve->lane($lane_id);
try {
$this->applyShellyTransportOverride($lane);
$lane->open($gate, $toggle_after);
$queued_gate_command = null;
if ($this->requestedShellyTransportOverride() === 'local') {
$queued_gate_command = $this->queueLocalLaneGateOpen($lane, $gate, $toggle_after);
} else {
$lane->open($gate, $toggle_after);
}
$response->success([
'lane_id' => $lane_id,
'gate' => $gate->name,
'opened' => true,
'queued' => $queued_gate_command !== null,
'batch' => $queued_gate_command,
'toggle_after' => $toggle_after,
'state' => $lane->getLaneState()->name,
'transport' => $this->requestedShellyTransportOverride(),
]);
], $queued_gate_command !== null ? 202 : 200);
} catch (\Exception $e) {
error_log('Failed to open self-serve lane gate ' . $gate->name . ' for lane ' . $lane_id . ': ' . $e->getMessage());
$response->error('Failed to open ' . strtolower($gate->name) . ' gate.', 400);
@@ -772,6 +790,83 @@ class moduleSelfServeRoute
'modules_selfserve_lane_gate_open' => 'Open ENTRANCE or EXIT gate for a self-serve lane'
]);
/** Modules > Self Serve > Lane > Hardware > Batch status */
$this->post('/modules/self-serve/lane/hardware/batch/status', function () {
global $response;
$selfserve = new selfserve();
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$lane = $selfserve->lane($lane_id);
try {
$this->applyShellyTransportOverride($lane);
$targets = self::isParametersSet(['targets']) ? (array)$this->getParameter('targets') : [
'MACHINE',
'PROGRAM_PICKER',
'CLEANER',
'ENTRANCE',
'EXIT',
];
$requests = $this->buildLaneHardwareBatchRequests($lane, $targets, false);
$user = (new authentication())->get_user();
$result = (new edge_gateway_manager())->queueRelayStatusBatch(
$this->departmentIdForLane($lane),
$requests,
$user instanceof users_o ? (int)$user->id : null,
$this->requestedShellyTransportOverride() === 'local'
);
$response->success($result, 202);
} catch (\Exception $e) {
$response->error('Failed to queue lane hardware status batch: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_machine_status_view' => 'Queue self-serve lane hardware status batch'
]);
/** Modules > Self Serve > Lane > Hardware > Batch set/open */
$this->post('/modules/self-serve/lane/hardware/batch/set', function () {
global $response;
$selfserve = new selfserve();
self::requireParameters(['lane_id', 'commands']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$commands = (array)$this->getParameter('commands');
$lane = $selfserve->lane($lane_id);
try {
$this->applyShellyTransportOverride($lane);
$requests = $this->buildLaneHardwareBatchRequests($lane, $commands, true);
$user = (new authentication())->get_user();
$result = (new edge_gateway_manager())->queueRelaySwitchBatch(
$this->departmentIdForLane($lane),
$requests,
$user instanceof users_o ? (int)$user->id : null,
$this->requestedShellyTransportOverride() === 'local'
);
$response->success($result, 202);
} catch (\Exception $e) {
$response->error('Failed to queue lane hardware command batch: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_machine_status_set' => 'Queue self-serve lane hardware command batch'
]);
/** Modules > Self Serve > Lane > Hardware > Batch poll */
$this->get('/modules/self-serve/lane/hardware/batch/{batch_id}', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_machine_status_view');
try {
$response->success((new edge_gateway_manager())->relayBatchStatus((string)$this->fromRoute('batch_id')));
} catch (\Exception $e) {
$response->error('Failed to get lane hardware batch status: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_machine_status_view' => 'Poll self-serve lane hardware batch'
]);
/** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER status */
$this->get('/modules/self-serve/lane/relay/machine_program_picker/status', function () {
global $response;
@@ -1330,8 +1425,7 @@ class moduleSelfServeRoute
{
global $response;
$user = (new authentication())->get_user();
if (!$user) {
if (!$this->hasAuthenticatedUserOrSubuser()) {
$response->error('Authentication failed. Invalid or missing token.', 401);
}
@@ -1344,7 +1438,7 @@ class moduleSelfServeRoute
return null;
}
if (self::hasPermission('list_own_department_selfserve_vehicle_conditions')) {
if (self::hasPermission($this->customerSelfServePermission())) {
$customer_number = $this->resolveEffectiveCustomerNumber();
if ($customer_number !== null && $customer_number > 0) {
return (int)$customer_number;
@@ -1353,7 +1447,7 @@ class moduleSelfServeRoute
$this->emitForbidden([
'modules_selfserve_lane_wash_in_progress_view',
'list_own_department_selfserve_vehicle_conditions',
$this->customerSelfServePermission(),
]);
return null;
}
@@ -1412,13 +1506,12 @@ class moduleSelfServeRoute
{
global $response;
$user = (new authentication())->get_user();
if (!$user) {
if (!$this->hasAuthenticatedUserOrSubuser()) {
$response->error('Authentication failed. Invalid or missing token.', 401);
}
if (!self::hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) {
$this->emitForbidden([self::CUSTOMER_SELFSERVE_PERMISSION]);
if (!self::hasPermission($this->customerSelfServePermission())) {
$this->emitForbidden([$this->customerSelfServePermission()]);
}
$customer_number = $this->resolveEffectiveCustomerNumber();
@@ -1429,6 +1522,17 @@ class moduleSelfServeRoute
return (int)$customer_number;
}
private function customerSelfServePermission(): \classes\permission_node
{
return self::definePermission(self::CUSTOMER_SELFSERVE_PERMISSION, subusers_permission_node_key::SELFSERVE_LIST);
}
private function hasAuthenticatedUserOrSubuser(): bool
{
$auth = new authentication();
return $auth->get_user() !== false || $auth->get_subuser() !== false;
}
private function findLatestActiveSelfServeSessionForCustomer(int $customer_number): selfserve_wash_sessions_o
{
if ($customer_number <= 0) {
@@ -1624,6 +1728,305 @@ class moduleSelfServeRoute
return $payload;
}
/**
* @return array<string,mixed>
* @throws \Exception
*/
private function queueLocalLaneGateOpen(selfserve_lane $lane, selfserve_lane_port $gate, int $toggleAfter): array
{
$requests = $this->buildLaneHardwareBatchRequests($lane, [[
'target' => $gate->name,
'action' => 'OPEN',
'toggle_after' => $toggleAfter,
]], true);
$user = (new authentication())->get_user();
return (new edge_gateway_manager())->queueRelaySwitchBatch(
$this->departmentIdForLane($lane),
$requests,
$user instanceof users_o ? (int)$user->id : null,
true
);
}
/**
* @param array<int,mixed> $items
* @return array<int,array<string,mixed>>
* @throws \Exception
*/
private function buildLaneHardwareBatchRequests(selfserve_lane $lane, array $items, bool $mutation): array
{
if (empty($lane->department_lane)) {
throw new \Exception("Department lane object not found for lane ID {$lane->id}");
}
$requests = [];
$seen = [];
foreach ($items as $item) {
$command = is_array($item) ? $item : ['target' => $item];
$target = strtoupper(trim((string)($command['target'] ?? $command['relay'] ?? '')));
if ($target === '') {
throw new \Exception('Lane hardware batch commands require a target');
}
if (isset($seen[$target])) {
throw new \Exception('Duplicate lane hardware target: ' . $target);
}
$seen[$target] = true;
$relayId = $this->relayIdForLaneHardwareTarget($lane, $target);
if ($relayId === '') {
throw new \Exception('No relay is configured for lane hardware target ' . $target);
}
if ($mutation) {
$this->requireLaneHardwareMutationPermission($target);
} else {
$this->requireLaneHardwareStatusPermission($target);
}
$request = [
'target' => $target,
'relay_id' => $relayId,
'relayId' => $relayId,
'lane_id' => (int)$lane->id,
'laneId' => (int)$lane->id,
];
if ($mutation) {
if (in_array($target, ['ENTRANCE', 'EXIT'], true)) {
$action = strtoupper(trim((string)($command['action'] ?? 'OPEN')));
if ($action !== 'OPEN') {
throw new \Exception($target . ' only supports action=OPEN');
}
$request['on'] = true;
$request['toggle_after'] = $this->normalizeBatchToggleAfter(
$command['toggle_after'] ?? $command['toggleAfter'] ?? $command['timer'] ?? 1,
self::MAX_GATE_OPEN_TOGGLE_AFTER_SECONDS
);
} else {
if (!array_key_exists('on', $command)) {
throw new \Exception($target . ' relay commands require an on boolean');
}
$on = is_bool($command['on'])
? $command['on']
: filter_var($command['on'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($on === null) {
throw new \Exception('Invalid on value for ' . $target . '. Expected boolean true/false.');
}
$request['on'] = (bool)$on;
if (isset($command['toggle_after']) || isset($command['toggleAfter']) || isset($command['timer'])) {
$request['toggle_after'] = $this->normalizeBatchToggleAfter(
$command['toggle_after'] ?? $command['toggleAfter'] ?? $command['timer'],
86400
);
}
}
}
$requests[] = $request;
}
if ($requests === []) {
throw new \Exception('At least one lane hardware target is required');
}
if (count($requests) > 5) {
throw new \Exception('Lane hardware batches are limited to five targets');
}
return $requests;
}
private function relayIdForLaneHardwareTarget(selfserve_lane $lane, string $target): string
{
$relayId = trim((string)match ($target) {
'MACHINE' => $lane->department_lane->relay_machine_id->value(),
'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => $lane->department_lane->relay_machine_program_picker_id->value(),
'CLEANER', 'MACHINE_CLEANER' => $lane->department_lane->relay_machine_cleaner_id->value(),
'ENTRANCE' => $lane->department_lane->relay_in_id->value(),
'EXIT' => $lane->department_lane->relay_out_id->value(),
default => throw new \Exception('Unsupported lane hardware target: ' . $target),
});
if ($relayId !== '') {
return $relayId;
}
return $this->edgeGatewayRelayIdForLaneHardwareTarget($lane, $target);
}
private function edgeGatewayRelayIdForLaneHardwareTarget(selfserve_lane $lane, string $target): string
{
$departmentId = $this->departmentIdForLane($lane);
if ($departmentId <= 0) {
return '';
}
$rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere([
'department_id' => $departmentId,
'deleted_at' => null,
], ['id']);
$laneScoped = [];
$unscoped = [];
foreach ($rows as $row) {
$binding = (new edge_gateway_relay_bindings_o())->select((int)$row['id']);
if (!$binding->exists()) {
continue;
}
$payload = $binding->asArray();
if (!$this->edgeGatewayBindingMatchesHardwareTarget($payload, $target)) {
continue;
}
if ($this->edgeGatewayBindingMatchesLane($payload, (int)$lane->id, $target)) {
$laneScoped[] = (string)$payload['relay_id'];
continue;
}
if ($this->edgeGatewayBindingHasConsumers($payload)) {
continue;
}
$unscoped[] = (string)$payload['relay_id'];
}
$candidates = $laneScoped !== [] ? array_values(array_unique($laneScoped)) : array_values(array_unique($unscoped));
if (count($candidates) === 1) {
return $candidates[0];
}
if (count($candidates) > 1) {
throw new \Exception('Multiple edge gateway relay bindings match lane hardware target ' . $target);
}
return '';
}
/**
* @param array<string,mixed> $binding
*/
private function edgeGatewayBindingMatchesHardwareTarget(array $binding, string $target): bool
{
$accepted = match ($target) {
'MACHINE' => ['MACHINE'],
'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => ['PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER'],
'CLEANER', 'MACHINE_CLEANER' => ['CLEANER', 'MACHINE_CLEANER'],
'ENTRANCE' => ['ENTRANCE', 'ENTRY'],
'EXIT' => ['EXIT'],
default => [],
};
if ($accepted === []) {
return false;
}
$metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
$values = [
$binding['role'] ?? null,
$binding['service'] ?? null,
$binding['slot'] ?? null,
$metadata['role'] ?? null,
$metadata['relay_role'] ?? null,
$metadata['service'] ?? null,
$metadata['slot'] ?? null,
];
foreach ((array)($binding['services'] ?? $metadata['services'] ?? []) as $service) {
$values[] = $service;
}
foreach ($values as $value) {
if (in_array($this->normalizeLaneHardwareTargetRole($value), $accepted, true)) {
return true;
}
}
return false;
}
/**
* @param array<string,mixed> $binding
*/
private function edgeGatewayBindingMatchesLane(array $binding, int $laneId, string $target): bool
{
$targetRoles = match ($target) {
'MACHINE' => ['MACHINE'],
'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => ['PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER'],
'CLEANER', 'MACHINE_CLEANER' => ['CLEANER', 'MACHINE_CLEANER'],
'ENTRANCE' => ['ENTRANCE', 'ENTRY'],
'EXIT' => ['EXIT'],
default => [],
};
$metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
$consumers = $binding['consumer_contexts'] ?? $binding['consumers'] ?? $metadata['consumer_contexts'] ?? $metadata['consumers'] ?? [];
if (!is_array($consumers)) {
return false;
}
foreach ($consumers as $consumer) {
if (!is_array($consumer)) {
continue;
}
if (strtolower(trim((string)($consumer['type'] ?? ''))) !== 'lane') {
continue;
}
if ((int)($consumer['id'] ?? 0) !== $laneId) {
continue;
}
$slot = $this->normalizeLaneHardwareTargetRole($consumer['slot'] ?? '');
if ($slot === '' || in_array($slot, $targetRoles, true)) {
return true;
}
}
return false;
}
/**
* @param array<string,mixed> $binding
*/
private function edgeGatewayBindingHasConsumers(array $binding): bool
{
$metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
$consumers = $binding['consumer_contexts'] ?? $binding['consumers'] ?? $metadata['consumer_contexts'] ?? $metadata['consumers'] ?? [];
return is_array($consumers) && $consumers !== [];
}
private function normalizeLaneHardwareTargetRole(mixed $value): string
{
return strtoupper(str_replace(['-', ' '], '_', trim((string)($value ?? ''))));
}
private function requireLaneHardwareStatusPermission(string $target): void
{
match ($target) {
'MACHINE' => self::requirePermission('modules_selfserve_lane_relay_machine_status_view'),
'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => self::requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_view'),
'CLEANER', 'MACHINE_CLEANER' => self::requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_view'),
'ENTRANCE', 'EXIT' => self::requirePermission('modules_selfserve_lane_gate_open'),
default => throw new \Exception('Unsupported lane hardware target: ' . $target),
};
}
private function requireLaneHardwareMutationPermission(string $target): void
{
match ($target) {
'MACHINE' => self::requirePermission('modules_selfserve_lane_relay_machine_status_set'),
'PROGRAM_PICKER', 'MACHINE_PROGRAM_PICKER' => self::requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_set'),
'CLEANER', 'MACHINE_CLEANER' => self::requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_set'),
'ENTRANCE', 'EXIT' => self::requirePermission('modules_selfserve_lane_gate_open'),
default => throw new \Exception('Unsupported lane hardware target: ' . $target),
};
}
private function normalizeBatchToggleAfter(mixed $value, int $max): int
{
$seconds = (int)$value;
if ($seconds <= 0) {
throw new \Exception('toggle_after must be greater than zero');
}
return min($seconds, $max);
}
/**
* @throws \Exception
*/
@@ -1685,7 +2088,7 @@ class moduleSelfServeRoute
$missing_permissions = $department_id > 0 ? ['department_access_' . $department_id] : [];
if ($allow_customer_self_serve) {
$missing_permissions[] = self::CUSTOMER_SELFSERVE_PERMISSION;
$missing_permissions[] = $this->customerSelfServePermission();
}
$this->emitForbidden($missing_permissions);
}
@@ -1729,7 +2132,7 @@ class moduleSelfServeRoute
return;
}
$this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]);
$this->emitForbidden([...$elevated_permissions, $this->customerSelfServePermission()]);
}
private function requireSelfServeLaneCommandPermission(
@@ -1760,7 +2163,7 @@ class moduleSelfServeRoute
$this->emitForbidden(
$allow_customer_self_serve
? [...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]
? [...$elevated_permissions, $this->customerSelfServePermission()]
: $elevated_permissions
);
}
@@ -1791,28 +2194,37 @@ class moduleSelfServeRoute
return;
}
$this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]);
$this->emitForbidden([...$elevated_permissions, $this->customerSelfServePermission()]);
}
protected function isSelfServeModuleEnabled(): bool
{
try {
return (bool)(new selfserve())->config->enabled->getVariableValue();
return (new selfserve())->config->enabled->isTrue();
} catch (\Throwable) {
return false;
}
}
protected function isMachineWashEnabled(): bool
{
try {
return (new selfserve())->config->machine_wash_enabled->isTrue();
} catch (\Throwable) {
return true;
}
}
protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool
{
return $customer_number > 0
&& $this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)
&& $this->hasPermission($this->customerSelfServePermission())
&& $this->isLaneSelfServeOperationallyEnabled($lane);
}
protected function canCustomerUseActiveSelfServeLane(selfserve_lane $lane, int $customer_number): bool
{
if ($customer_number <= 0 || !$this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) {
if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission())) {
return false;
}
@@ -1859,7 +2271,7 @@ class moduleSelfServeRoute
protected function canCustomerUseActiveSelfServeLaneSession(selfserve_lane $lane, int $customer_number): bool
{
if ($customer_number <= 0 || !$this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) {
if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission())) {
return false;
}
@@ -255,11 +255,13 @@ class moduleXLVaskRoute
$this->get('/modules/xlvask/tasks/import-usage', function () {
global $response;
self::requirePermission('modules_xlvask_import_usage');
$dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null;
$dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null;
// Create the xlvask_usage_logs_o object
$xlvask_usage_logs_o = new \objects\xlvask_usage_logs_o();
// Import usage logs
$xlvask_usage_logs_o->importUsageLogs();
(new xlvask_automation_service())->runPending(null, null, [], 100, null);
$xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo);
(new xlvask_automation_service())->runPending($dateFrom, $dateTo, [], 100, null);
// Response
$response->success(
'Usage logs imported',
+46 -13
View File
@@ -41,18 +41,9 @@ class orderBookingRoute
$po = self::getTargetPo(); // String | Null
$pickup = self::getTargetPickup(); // Bool | Null
$items = self::getTargetItems(); // Array of order_items_o objects
/**
* Permissions (clean helper)
*/
$permission_own = self::definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD);
$permission_other = self::definePermission('add_bookings');
self::allowOwnOrDepartmentAccess(
$permission_own,
$permission_other,
$this->requireOrderBookingCreateAccess(
(int)$customer_number->customer_number->value(),
(int)$department->id,
null,
'You do not have permission to create this order booking.'
(int)$department->id
);
/**
* Input data
@@ -96,8 +87,8 @@ class orderBookingRoute
$response->success($order_bookings_o->asArray());
},
[
'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD and X-Customer-Number header.',
'add_bookings' => 'Permission to create department order bookings.'
'add_bookings' => 'Permission to create order bookings for another customer or department scope.',
'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD.'
]
);
@@ -659,6 +650,48 @@ class orderBookingRoute
return $object;
}
private function requireOrderBookingCreateAccess(int $targetCustomerNumber, int $departmentId): void
{
$auth = new authentication();
if ($auth->get_subuser() !== false && $this->isOwnCustomerContext($targetCustomerNumber)) {
$permissionOwn = self::definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD);
if (!self::hasPermission($permissionOwn, $targetCustomerNumber)) {
$this->emitForbidden([$permissionOwn]);
}
return;
}
if (
$auth->get_user() !== false
&& self::hasPermission('user')
&& $this->isOwnCustomerContext($targetCustomerNumber)
) {
return;
}
$permissionOther = self::definePermission('add_bookings');
if (!self::hasPermission($permissionOther)) {
$this->emitForbidden([$permissionOther]);
}
self::requireDepartmentAccess((string)$departmentId);
}
private function isOrderBookingCustomerSession(): bool
{
try {
$auth = new authentication();
if ($auth->get_subuser() !== false) {
return true;
}
return $auth->get_user() !== false && self::hasPermission('user');
} catch (Exception) {
return false;
}
}
/**
* @throws Exception If the Department is invalid.
*/
@@ -612,21 +612,46 @@ class orderInvoicesRoute
$preview ? 'User previewed splitting collected order invoices by month' : 'User split collected order invoices by order month'
);
$date_from = $db->escape_string($date_range['dateFrom']);
$date_to = $db->escape_string($date_range['dateTo']);
$sql = "SELECT DISTINCT invoice_collection_id
FROM orders
WHERE created_at BETWEEN '$date_from' AND '$date_to'
AND invoice_collection_id IS NOT NULL
AND invoice_collection_id > 0
AND deleted_at IS NULL";
$query_result = $db->query($sql);
$invoice_collection_ids = [];
while ($row = $query_result->fetch_assoc()) {
$invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0);
if ($invoice_collection_id > 0) {
if (self::isParametersSet(['invoice_collection_ids'])) {
$invoice_collection_ids_raw = self::getParameter('invoice_collection_ids');
if (!is_array($invoice_collection_ids_raw)) {
$response->error('invoice_collection_ids must be an array', 400);
}
foreach ($invoice_collection_ids_raw as $invoice_collection_id_raw) {
if (is_array($invoice_collection_id_raw) || is_object($invoice_collection_id_raw) || !is_numeric($invoice_collection_id_raw)) {
$response->error('invoice_collection_ids must contain only positive integer ids', 400);
}
$invoice_collection_id = (int)$invoice_collection_id_raw;
if ($invoice_collection_id < 1 || $invoice_collection_id > 999999999) {
$response->error('invoice_collection_ids must contain only positive integer ids', 400);
}
$invoice_collection_ids[] = $invoice_collection_id;
}
$invoice_collection_ids = array_values(array_unique($invoice_collection_ids));
if (empty($invoice_collection_ids)) {
$response->error('invoice_collection_ids must contain at least one id', 400);
}
} else {
$date_from = $db->escape_string($date_range['dateFrom']);
$date_to = $db->escape_string($date_range['dateTo']);
$sql = "SELECT DISTINCT invoice_collection_id
FROM orders
WHERE created_at BETWEEN '$date_from' AND '$date_to'
AND invoice_collection_id IS NOT NULL
AND invoice_collection_id > 0
AND deleted_at IS NULL";
$query_result = $db->query($sql);
while ($row = $query_result->fetch_assoc()) {
$invoice_collection_id = (int)($row['invoice_collection_id'] ?? 0);
if ($invoice_collection_id > 0) {
$invoice_collection_ids[] = $invoice_collection_id;
}
}
}
$items = [];
+70 -10
View File
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\customer_product_rule_service;
use objects\logs_o;
use objects\order_items_o;
use objects\orders_o;
@@ -70,10 +71,29 @@ class orderItemsRoute
$price = (int)self::getParameter('price');
}
}
$order = (new orders_o())->getOrderById((int)$data['order_id']);
if (!$order->exists()) {
$response->error('Order not found', 404);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$product = (new products_o())->getProductById((int)$data['product_id']);
if (!$product->exists()) {
$response->error('Product not found', 404);
}
$customerRuleViolation = (new customer_product_rule_service())
->firstViolationForOrderItem((int)$data['order_id'], (int)$data['product_id'], $related_item_id);
if ($customerRuleViolation !== null) {
(new logs_o())->add(
'order_items',
'global',
1,
$user->id,
'ORDER_ITEM_RESTRICTED_BY_CUSTOMER_RULE',
'Blocked product ' . (int)$data['product_id'] . ' on order ' . (int)$data['order_id'] . ' by rule ' . $customerRuleViolation['rule']
);
$response->error($customerRuleViolation['message'], 400);
}
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
}
@@ -155,18 +175,38 @@ class orderItemsRoute
$this->delete('/order/items', function () {
// Require the user to be logged in
global $response;
global $response, $db;
$this->requirePermission('delete_order_items');
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
// Get the query data
$data = $_GET;
// Check if the required fields are set
if (!isset($data['id'])) {
// Get the order item id from the query string or request body
$itemIdRaw = $this->fromRequest('id');
if ($itemIdRaw === null || $itemIdRaw === '') {
$response->error('Order Item ID is required', 400);
}
$data = ['id' => $itemIdRaw];
// Look up the order item to check department access
$itemId = (int)$data['id'];
$stmt = $db->prepare('SELECT oi.order_id FROM order_items oi WHERE oi.id = ? LIMIT 1');
if ($stmt === false) {
(new logs_o())->add('order_items', 'global', 1, 0, 'DELETE_ORDER_ITEMS', 'Database error while preparing department access check query');
$response->error('Database error while checking department access', 500);
}
$stmt->bind_param('i', $itemId);
$stmt->execute();
$orderItemRow = $stmt->get_result()->fetch_assoc();
$stmt->close();
if ($orderItemRow !== null) {
$orderForAccess = (new orders_o())->getOrderById((int)$orderItemRow['order_id']);
if (!$orderForAccess->exists()) {
$response->error('Order not found', 404);
}
self::requireDepartmentAccess((string)(int)$orderForAccess->department_id->value());
} else {
$response->error('Order item not found', 404);
}
// Delete the order item
(new order_items_o())->removeOrderItem((int)$data['id']);
// Return the list of departments
@@ -187,7 +227,7 @@ class orderItemsRoute
$this->put('/order/items', function () {
// Require the user to be logged in
global $response;
global $response, $db;
$this->requirePermission('edit_order_items');
// Get the user object
$user = (new authentication())->get_user();
@@ -212,19 +252,39 @@ class orderItemsRoute
$response->error('Quantity is required', 400);
}
$orderItem = (new order_items_o())->getOrderItemById((int)$data['id']);
$orderItemId = (int)$data['id'];
$orderItem = (new order_items_o())->getOrderItemById($orderItemId);
if (!$orderItem->exists()) {
$response->error('Order item not found', 404);
}
$product = (new products_o())->getProductById((int)$orderItem->product_id->value());
if ($product->requiresOrderItemNote() && trim((string)$data['notes']) === '') {
$orderItemContextResult = $db->query(
"SELECT oi.order_id, oi.product_id, p.name AS product_name, p.requires_note AS product_requires_note
FROM order_items oi
LEFT JOIN products p ON p.id = oi.product_id
WHERE oi.id = {$orderItemId}
LIMIT 1"
);
$orderItemContext = $orderItemContextResult ? $orderItemContextResult->fetch_assoc() : null;
if ($orderItemContext === null) {
$response->error('Order item not found', 404);
}
if ($orderItemContext['product_id'] === null || $orderItemContext['product_name'] === null) {
$response->error('Product not found', 404);
}
if (products_o::productDataRequiresOrderItemNote([
'id' => (int)$orderItemContext['product_id'],
'name' => (string)$orderItemContext['product_name'],
'requires_note' => (bool)$orderItemContext['product_requires_note'],
]) && trim((string)$data['notes']) === '') {
$response->error('Notes is required for this product', 400);
}
$order = (new orders_o())->getOrderById((int)$orderItem->order_id->value());
$order = (new orders_o())->getOrderById((int)$orderItemContext['order_id']);
if (!$order->exists()) {
$response->error('Order not found', 404);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$canAccessAllOrderItems = $this->hasPermission('list_order_items');
if (!$canAccessAllOrderItems && !$order->isOwnOrder((int)$user->customer_number->value())) {
+13 -6
View File
@@ -172,6 +172,8 @@ class ordersRoute
if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) {
$response->error('Department not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$data['department_id']);
// Make sure the customer number set is valid
$targetUser = (new users_o())->getUserByCustomerNumber((int)$data['customer_id']);
if (!$targetUser->exists()) {
@@ -472,6 +474,8 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Get the base64 file
$base64_file = (string)$this->getParameter('base64_file');
$attachment_store = new attachment_store();
@@ -530,6 +534,8 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Delete the attachment
$order->removeAttachment((int)$attachment_id);
// Log the incident
@@ -568,6 +574,8 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 400);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Mark the order as completed
$order->markAsCompleted((string)$user->display_name->value());
// Log the incident
@@ -1154,7 +1162,8 @@ class ordersRoute
}
// Admin/department path (requires edit_order)
self::requirePermission($permission_other);
/** Departmental access */
/** Departmental access — user must have access to the order's current department */
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$originalCustomerNumber = (int)$order->customer_id->value();
$newCustomerNumber = $originalCustomerNumber;
$shouldAutoReassignInvoiceCollection = false;
@@ -1219,6 +1228,8 @@ class ordersRoute
if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) {
$response->error('Department not found', 400);
}
// Check if the user has access to the target department
self::requireDepartmentAccess((string)(int)$data['department_id']);
$order->department_id->set((int)$data['department_id']);
}
// If the booking ID is set, validate it
@@ -1362,11 +1373,7 @@ class ordersRoute
{
try {
$user = (new authentication())->get_user();
if ($user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber) {
return true;
}
return $this->hasDepartmentAccess((string)$departmentId);
return $user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber;
} catch (\Throwable) {
return false;
}
+55 -35
View File
@@ -11,21 +11,48 @@ class passkeysRoute
{
use route_t;
/**
* @return array{principal:object,user_id:int,is_subuser:bool}
*/
private function resolvePasskeyPrincipal(string $classicUserPermission): array
{
global $response;
$auth = new authentication();
$subuser = $auth->get_subuser();
if ($subuser !== false) {
return [
'principal' => $subuser,
'user_id' => (int)$subuser->id,
'is_subuser' => true,
];
}
self::requirePermission($classicUserPermission);
$user = $auth->get_user();
if (!$user) {
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_AUTH', 'User not logged in');
$response->error('Invalid session', 400);
}
return [
'principal' => $user,
'user_id' => (int)$user->id,
'is_subuser' => false,
];
}
public function run(): void
{
// List passkeys for current authenticated user
$this->get('/account/security/passkeys', function () {
global $response;
self::requirePermission('user_security_passkeys_list');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_LIST', 'User not logged in');
$response->error('Invalid session', 400);
}
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_list');
$passkeys = new passkeys_o();
// Restrict to current user (customer) and non-subuser records
$passkeys->setAdditionalWhereClause('`user_id` = ' . (int)$user->id . ' AND `is_subuser` = 0');
$passkeys->setAdditionalWhereClause(
'`user_id` = ' . (int)$principal['user_id'] . ' AND `is_subuser` = ' . ($principal['is_subuser'] ? '1' : '0')
);
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
// $o is an associative array from the database
$transports = null;
@@ -45,7 +72,7 @@ class passkeysRoute
];
});
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_LIST', 'Listed passkeys');
(new logs_o())->add('user_security', 'global', 1, $principal['user_id'], 'USER_SECURITY_PASSKEYS_LIST', 'Listed passkeys');
$response->success($list);
}, [
'user_security_passkeys_list' => 'List passkeys for the authenticated user',
@@ -54,12 +81,7 @@ class passkeysRoute
// Create/add a passkey (store after client-side WebAuthn attestation)
$this->post('/account/security/passkeys', function () {
global $response;
self::requirePermission('user_security_passkeys_create');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_CREATE', 'User not logged in');
$response->error('Invalid session', 400);
}
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_create');
self::requireParameters(['credential_id', 'public_key', 'algorithm', 'transports']);
@@ -100,9 +122,9 @@ class passkeysRoute
}
$obj = new passkeys_o();
$obj->add((int)$user->id, false, $credential_id, $public_key, $algorithm, (array)$transports, $name);
$obj->add((int)$principal['user_id'], (bool)$principal['is_subuser'], $credential_id, $public_key, $algorithm, (array)$transports, $name);
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_CREATE', 'Created passkey: ' . $obj->id);
(new logs_o())->add('user_security', 'global', 1, $principal['user_id'], 'USER_SECURITY_PASSKEYS_CREATE', 'Created passkey: ' . $obj->id);
$response->success(['id' => $obj->id]);
}, [
'user_security_passkeys_create' => 'Create/add a new passkey for the authenticated user',
@@ -111,12 +133,7 @@ class passkeysRoute
// Rename a passkey
$this->patch('/account/security/passkeys/{id}', function () {
global $response;
self::requirePermission('user_security_passkeys_rename');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_RENAME', 'User not logged in');
$response->error('Invalid session', 400);
}
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_rename');
$id = (int)self::fromRoute('id');
self::requireParameterIntPositive($id, 'id');
@@ -127,13 +144,17 @@ class passkeysRoute
self::requireMaxLength('name', 255);
$obj = (new passkeys_o())->select($id);
if (!$obj->exists() || (int)$obj->user_id->value() !== (int)$user->id || (int)$obj->is_subuser->value() !== 0) {
(new logs_o())->add('user_security', 'global', 0, $user->id, 'USER_SECURITY_PASSKEYS_RENAME', 'Passkey not found or not owned');
if (
!$obj->exists()
|| (int)$obj->user_id->value() !== (int)$principal['user_id']
|| (bool)$obj->is_subuser->value() !== (bool)$principal['is_subuser']
) {
(new logs_o())->add('user_security', 'global', 0, $principal['user_id'], 'USER_SECURITY_PASSKEYS_RENAME', 'Passkey not found or not owned');
$response->error('Not found', 404);
}
$obj->update(['name' => $name]);
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_RENAME', 'Renamed passkey ' . $id);
(new logs_o())->add('user_security', 'global', 1, $principal['user_id'], 'USER_SECURITY_PASSKEYS_RENAME', 'Renamed passkey ' . $id);
$response->success(['message' => 'Renamed', 'id' => $id]);
}, [
'user_security_passkeys_rename' => 'Rename a passkey that belongs to the authenticated user',
@@ -142,24 +163,23 @@ class passkeysRoute
// Delete a passkey (soft delete)
$this->delete('/account/security/passkeys/{id}', function () {
global $response;
self::requirePermission('user_security_passkeys_delete');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_DELETE', 'User not logged in');
$response->error('Invalid session', 400);
}
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_delete');
$id = (int)self::fromRoute('id');
self::requireParameterIntPositive($id, 'id');
$obj = (new passkeys_o())->select($id);
if (!$obj->exists() || (int)$obj->user_id->value() !== (int)$user->id || (int)$obj->is_subuser->value() !== 0) {
(new logs_o())->add('user_security', 'global', 0, $user->id, 'USER_SECURITY_PASSKEYS_DELETE', 'Passkey not found or not owned');
if (
!$obj->exists()
|| (int)$obj->user_id->value() !== (int)$principal['user_id']
|| (bool)$obj->is_subuser->value() !== (bool)$principal['is_subuser']
) {
(new logs_o())->add('user_security', 'global', 0, $principal['user_id'], 'USER_SECURITY_PASSKEYS_DELETE', 'Passkey not found or not owned');
$response->error('Not found', 404);
}
$obj->delete();
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_DELETE', 'Deleted passkey ' . $id);
(new logs_o())->add('user_security', 'global', 1, $principal['user_id'], 'USER_SECURITY_PASSKEYS_DELETE', 'Deleted passkey ' . $id);
$response->success(['message' => 'Deleted', 'id' => $id]);
}, [
'user_security_passkeys_delete' => 'Delete a passkey that belongs to the authenticated user',
+91 -42
View File
@@ -22,21 +22,23 @@ class productsRoute
*/
private function getCustomerIfProvided(): ?users_o
{
global $response;
if (self::isParametersSet(['customer_id'])) {
$customerId = (int)self::getParameter('customer_id');
try {
$customerObject = (new users_o())->getUserByCustomerNumber((int)$customerId);
if ($customerObject->exists()) {
return $customerObject;
}
} catch (\Exception $e) {
// Log the incident
(new logs_o())->add('products', 'global', 3, 0, 'GET_CUSTOMER_FAILED', 'Failed to get customer with id ' . $customerId . '. Error: ' . $e->getMessage());
// Return null
return null;
}
$customerId = $this->getOptionalPositiveIntParameter('customer_id');
if ($customerId === null) {
return null;
}
try {
$customerObject = (new users_o())->getUserByCustomerNumber($customerId);
if ($customerObject->exists()) {
return $customerObject;
}
} catch (\Exception $e) {
// Log the incident
(new logs_o())->add('products', 'global', 3, 0, 'GET_CUSTOMER_FAILED', 'Failed to get customer with id ' . $customerId . '. Error: ' . $e->getMessage());
// Return null
return null;
}
return null;
}
@@ -45,12 +47,62 @@ class productsRoute
* @return int|null
*/
private function getDepartmentIdIfProvided(): ?int
{
return $this->getOptionalPositiveIntParameter('department_id');
}
private function getOptionalPositiveIntParameter(string $parameter): ?int
{
global $response;
if (self::isParametersSet(['department_id'])) {
return (int)self::getParameter('department_id');
if (!self::isParametersSet([$parameter])) {
return null;
}
return null;
$value = self::getParameter($parameter);
if ($this->isNullLikeOptionalParameter($value)) {
return null;
}
$parsed = null;
if (is_int($value)) {
$parsed = $value;
} elseif (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1) {
$parsed = (int)trim($value);
} else {
$response->error('Invalid ' . $parameter, 400);
}
if ($parsed === null || $parsed <= 0) {
$response->error('Invalid ' . $parameter, 400);
}
return $parsed;
}
private function isNullLikeOptionalParameter(mixed $value): bool
{
if ($value === null) {
return true;
}
if (!is_string($value)) {
return false;
}
return in_array(strtolower(trim($value)), ['', 'null', 'undefined'], true);
}
private function assertCanUseDepartmentPricing(mixed $user, ?int $departmentId): void
{
if (!$user instanceof users_o || $departmentId === null) {
return;
}
if ($this->hasPermission('superuser_fetch_department')) {
return;
}
$this->requirePermission('department_access_' . $departmentId);
}
/**
@@ -59,11 +111,7 @@ class productsRoute
*/
private function getCategoryIfProvided(): ?int
{
global $response;
if (self::isParametersSet(['category'])) {
return (int)self::getParameter('category');
}
return null;
return $this->getOptionalPositiveIntParameter('category');
}
/**
@@ -72,11 +120,7 @@ class productsRoute
*/
private function getProductIdIfProvided(): ?int
{
global $response;
if (self::isParametersSet(['id'])) {
return (int)self::getParameter('id');
}
return null;
return $this->getOptionalPositiveIntParameter('id');
}
/**
@@ -91,12 +135,14 @@ class productsRoute
// Check if the departmentId is set
if ($departmentId) {
// Apply the departments unique pricing
$products = (new products_o())->applyDepartmentPricing($products, $departmentId);
$products = (new products_o())->applyDepartmentPricing($products, $departmentId, true);
}
// Check if the customer is set
if ($customer !== null) {
// Apply the customers unique discounts
$products = (new products_o())->applyCustomerDiscounts($products, $customer);
} else {
$products = products_o::stripDepartmentPriceSources($products);
}
return $products;
}
@@ -195,12 +241,14 @@ class productsRoute
// Check if the request was successful
if ($user || $isProductDetailsRestricted) {
// Define the variables
$customer = self::getCustomerIfProvided(); // This is only used if the customer_id parameter is provided
$departmentId = self::getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
$category = self::getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
$productId = self::getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
$customer = $this->getCustomerIfProvided(); // This is only used if the customer_id parameter is provided
$departmentId = $this->getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
$category = $this->getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
$productId = $this->getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
$useFinalPrice = self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true';
// Check if the "final_price" parameter is set, and true.
if (self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true') {
if ($useFinalPrice) {
$this->assertCanUseDepartmentPricing($user, $departmentId);
// Determine the products to return
if ($category) {
// Get products in the category
@@ -258,17 +306,17 @@ class productsRoute
);
}
// Check if the category is set in the request
$data = $_GET ?? [];
// Check if the category is set
if (isset($data['category'])) {
if ($category !== null) {
// Log the incident
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products in category ' . $data['category']);
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products in category ' . $category);
// Return the list of products
$products = (new products_o())->listObjectsByCategory($data['category']);
$products = (new products_o())->listObjectsByCategory($category);
// Check if the department_id is set
if (isset($data['department_id'])) {
if ($departmentId !== null) {
$this->assertCanUseDepartmentPricing($user, $departmentId);
// Apply the departments unique pricing
$products = (new products_o())->applyDepartmentPricing((array)$products, (int)$data['department_id']);
$products = (new products_o())->applyDepartmentPricing((array)$products, $departmentId);
}
$response->success(
array_map(function ($product) use ($isProductDetailsRestricted) {
@@ -279,9 +327,10 @@ class productsRoute
// Log the incident
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products');
// Check if the department_id is set
if (isset($data['department_id'])) {
if ($departmentId !== null) {
$this->assertCanUseDepartmentPricing($user, $departmentId);
// Get all product ids contained in a category attached to the department
$departmentSpecificProducts = (new departments_o())->select((int)$data['department_id'])->getAllProductInDepartmentCategories();
$departmentSpecificProducts = (new departments_o())->select($departmentId)->getAllProductInDepartmentCategories();
// Get the product ids as an array
$departmentSpecificProductIds = array_map(function ($product) {
return $product->id;
@@ -296,7 +345,7 @@ class productsRoute
(new products_o())->forceRestrictFilters([
'id' => $departmentSpecificProductIds,
])
), (int)$data['department_id'])
), $departmentId)
);
}
// Return the list of products
+21 -1
View File
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\limited_backoffice_service;
use objects\groups_o;
use objects\logs_o;
use traits\route_t;
@@ -106,6 +107,25 @@ class rolesRoute
]
);
self::get('/roles/limited-backoffice-permission-templates', function () {
global $response;
self::requirePermission('superuser');
self::requirePermission('add_role_permission');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('roles', 'global', 1, $user->id, 'ROLES', 'User accessed limited backoffice role permission templates');
$response->success((new limited_backoffice_service())->rolePermissionTemplates());
} else {
(new logs_o())->add('roles', 'global', 0, 0, 'ROLES', 'User tried to access limited backoffice role permission templates without a valid session');
$response->error('Invalid session', 400);
}
},
[
'superuser' => 'Access the superuser interface',
'add_role_permission' => 'Add a permission to a role'
]
);
self::post('/roles/permissions', function () {
// Require the user to be logged in
global $response;
@@ -182,4 +202,4 @@ class rolesRoute
]
);
}
}
}
+110 -78
View File
@@ -231,7 +231,57 @@ class subusersRoute
private function buildSetupLink(string $token): string
{
return 'https://truckwash.io/complete-registration?token=' . $token;
$frontendBaseUrl = trim((string)(
getenv('FRONTEND_URL')
?: getenv('APP_URL')
?: ($_SERVER['FRONTEND_URL'] ?? '')
?: ($_SERVER['APP_URL'] ?? '')
?: 'https://truckwash.io'
));
$frontendBaseUrl = rtrim($frontendBaseUrl !== '' ? $frontendBaseUrl : 'https://truckwash.io', '/');
return $frontendBaseUrl . '/complete-registration?token=' . rawurlencode($token);
}
private function clientThrottleIp(): string
{
$remoteAddress = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
return $remoteAddress !== '' ? $remoteAddress : 'unknown';
}
private function recordThrottleAttempt(string $scope, string $identifier, int $limit, int $windowSeconds): ?string
{
global $response;
if (!defined('redis')) {
return null;
}
$safeScope = preg_replace('/[^a-z0-9:_-]/i', '_', $scope);
$key = 'subusers_route_throttle:' . $safeScope . ':' . hash(
'sha256',
$this->clientThrottleIp() . ':' . $identifier
);
$redis = constant('redis');
$attempts = (int)($redis->get($key) ?? '0');
if ($attempts >= $limit) {
$response->error('Too many attempts. Please wait and try again.', 429);
}
$redis->setEx($key, (string)($attempts + 1), $windowSeconds);
return $key;
}
private function clearThrottleAttempt(?string $key): void
{
if ($key === null || !defined('redis')) {
return;
}
constant('redis')->delete($key);
}
private function subuserAuthFailure(): void
{
global $response;
$response->error('Invalid credentials', 401);
}
private function issueSetupInvite(subusers_o $subuser): array
@@ -272,7 +322,7 @@ class subusersRoute
$delivery = [
'channel' => 'sms',
'status' => 'failed',
'message' => $exception->getMessage(),
'message' => 'Invite delivery failed.',
];
}
@@ -330,7 +380,7 @@ class subusersRoute
'subuser' => $subuser->id,
'enabled' => 1,
'deleted_at' => null,
], ['permissions', 'billing_customer_number']);
], ['id', 'permissions', 'billing_customer_number']);
$customerNames = $this->resolveCustomerNames(array_map(
static fn (array $grant): int => (int)($grant['billing_customer_number'] ?? 0),
$grants
@@ -346,6 +396,7 @@ class subusersRoute
'grants' => array_map(function ($grant) use ($customerNames) {
$customerNumber = (int)$grant['billing_customer_number'];
return [
'grant_id' => isset($grant['id']) ? (int)$grant['id'] : null,
'name' => $this->resolveCustomerName($customerNumber, $customerNames),
'billing_customer_number' => $customerNumber,
'permissions' => subuser_grants_o::normalizePermissionsValue($grant['permissions'] ?? null),
@@ -762,28 +813,9 @@ class subusersRoute
self::requireType($note, self::type_string());
self::requireMaxLength('note', 65535);
}
$permissions = null;
if (self::isParametersSet(['permissions'])) {
$raw = self::getParameter('permissions');
// Expect array (already parsed) or JSON string
if (is_string($raw)) {
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
$response->error('Invalid permissions payload', 400);
}
$permissions = $decoded;
} elseif (is_array($raw)) {
$permissions = $raw;
} else {
$response->error('Invalid permissions type', 400);
}
// Validate each permission is a known key
foreach ($permissions as $perm) {
if (!is_string($perm) || subusers_permission_node_key::tryFrom($perm) === null) {
$response->error('Unknown permission key: ' . (string)$perm, 400);
}
}
}
$permissions = self::isParametersSet(['permissions'])
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
: null;
try {
$grant = (new subuser_grants_o())->add($customer_number, $subuser_id, (bool)$enabled, $note, $permissions);
$response->success(['grant' => $grant->asArray()]);
@@ -834,25 +866,7 @@ class subusersRoute
$grant->note->set($note);
}
if (self::isParametersSet(['permissions'])) {
$raw = self::getParameter('permissions');
// Expect array (already parsed) or JSON string
if (is_string($raw)) {
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
$response->error('Invalid permissions payload', 400);
}
$permissions = $decoded;
} elseif (is_array($raw)) {
$permissions = $raw;
} else {
$response->error('Invalid permissions type', 400);
}
// Validate each permission is a known key
foreach ($permissions as $perm) {
if (!is_string($perm) || subusers_permission_node_key::tryFrom($perm) === null) {
$response->error('Unknown permission key: ' . (string)$perm, 400);
}
}
$permissions = $this->parsePermissionsPayload(self::getParameter('permissions'), []);
$grant->permissions->set($permissions);
}
$response->success($grant->asArray());
@@ -866,6 +880,14 @@ class subusersRoute
$this->get('/subusers/permission-nodes', function () {
global $response;
$canUseGlobalManagement = self::hasPermission('manage_subuser_grants')
|| self::hasPermission('list_subusers')
|| self::hasPermission('add_subusers')
|| self::hasPermission('edit_subusers');
if (!$canUseGlobalManagement) {
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST);
}
// Build groups
$groups = [
new subusers_permission_nodes_bookings(),
@@ -893,7 +915,13 @@ class subusersRoute
];
}
$response->success($out);
}, []);
}, [
'list_own_subusers' => 'List chauffeur permission nodes for own customer. Subusers require node: SUBUSERS_LIST and X-Customer-Number header.',
'manage_subuser_grants' => 'List chauffeur permission nodes for administrative grant management.',
'list_subusers' => 'List chauffeur permission nodes for superuser management.',
'add_subusers' => 'List chauffeur permission nodes while inviting chauffeurs.',
'edit_subusers' => 'List chauffeur permission nodes while editing chauffeur grants.',
]);
$this->post('/subusers', function () {
global /** @var response $response */
@@ -948,15 +976,7 @@ class subusersRoute
(int)$phone_country_code,
(int)$phone
);
// Send an SMS with a link to complete the registration process.
$gatewayAPI = new gatewayapi();
if ($gatewayAPI->isEnabled()) {
$token = $subuser->generateSetupToken();
$link = 'https://truckwash.io/complete-registration?token=' . $token;
$message = 'Tak for din oprettelse af chaufførkonto hos Truck Wash! Klik på linket for at fuldføre registreringen: ' . $link;
$phone_number_array = [(string)$phone_country_code . (string)$phone];
$gatewayAPI->send($phone_number_array, $message);
}
$invite = $this->issueSetupInvite($subuser);
// Add the grant request
$subuser_grants_o = new subuser_grants_o();
try {
@@ -964,7 +984,7 @@ class subusersRoute
} catch (Exception $e) {
$response->error('Failed to add subuser grant', 500);
}
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber]);
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber, 'invite' => $invite]);
// Code for creating a new subuser would go here
});
$this->get('/subusers/setup', function () {
@@ -972,11 +992,13 @@ class subusersRoute
global $response;
self::requireParameters(['token']);
$token = self::getParameter('token');
$setupThrottleKey = $this->recordThrottleAttempt('setup_token', 'token-validation', 20, 15 * 60);
// Get the subuser with the setup token
$subuser = (new subusers_o())->getSubuserBySetupToken($token);
if ($subuser === null) {
$response->error('Invalid or expired token', 400);
}
$this->clearThrottleAttempt($setupThrottleKey);
$response->success(['message' => 'Token is valid', 'subuser_id' => $subuser->id]);
});
$this->post('/subusers/setup', function () {
@@ -986,6 +1008,7 @@ class subusersRoute
$token = (string)self::getParameter('token');
$password = (string)self::getParameter('password');
$name = (string)self::getParameter('name');
$setupThrottleKey = $this->recordThrottleAttempt('setup_complete', 'token-complete', 20, 15 * 60);
$this->requireSubuserPasswordPolicy($password);
self::requireType($name, self::type_string());
self::requireMinLength('name', 3);
@@ -1017,18 +1040,24 @@ class subusersRoute
if ($subuser === null) {
$response->error('Invalid or expired token', 400);
}
$this->assertSubuserIdentifiersAvailable(
null,
null,
$username,
$email,
(int)$subuser->id
);
// Set the password for the subuser
try {
$subuser->setPassword($password);
if (!empty($username) || !empty($email) || !empty($name)) {
$subuser->update([
...(!empty($username) ? ['username' => $username] : []),
...(!empty($email) ? ['email' => $email] : []),
...(!empty($name) ? ['name' => $name] : []),
]);
}
$subuser->update([
'password' => password_hash($password, PASSWORD_DEFAULT),
'name' => $name,
...(!empty($username) ? ['username' => $username] : []),
...(!empty($email) ? ['email' => $email] : []),
]);
// Invalidate the setup token
$subuser->invalidateSetupToken($token);
$this->clearThrottleAttempt($setupThrottleKey);
$response->success(['message' => 'Complete registration successful']);
} catch (Exception $e) {
$response->error($e->getMessage(), 400);
@@ -1067,23 +1096,34 @@ class subusersRoute
} else {
$response->error('You must provide either phone_country_code & phone, subuser_id or username', 400);
}
$identifier = $username !== null
? 'username:' . strtolower($username)
: ($subuser_id !== null
? 'id:' . (string)$subuser_id
: 'phone:' . (string)$phone_country_code . ':' . (string)$phone);
$authThrottleKey = $this->recordThrottleAttempt('auth_password', $identifier, 10, 15 * 60);
// Get the subuser based on the provided username type
$subuser = null;
if ($phone_country_code !== null && $phone !== null) {
$subuser = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
} elseif ($subuser_id !== null) {
$subuser = (new subusers_o())->select($subuser_id);
$candidate = (new subusers_o())->select($subuser_id);
if ($candidate->exists()) {
$candidate->getObjectProperties();
$subuser = $candidate;
}
} elseif ($username !== null) {
$subuser = (new subusers_o())->getSubuserByUsername($username);
}
if ($subuser === null) {
$response->error('Subuser not found', 404);
$this->subuserAuthFailure();
}
self::requireParameters(['password']);
$password = (string)self::getParameter('password');
$this->requireSubuserPasswordPolicy($password);
try {
if (password_verify($password, $subuser->password->value())) {
$passwordHash = $subuser->password->value();
if (is_string($passwordHash) && $passwordHash !== '' && password_verify($password, $passwordHash)) {
$this->clearThrottleAttempt($authThrottleKey);
if ($subuser->isTwoFactorEnabled()) {
$token = (new authentication())->create_2fa_token($subuser->id, '2FA_VERIFICATION_SUBUSER');
$response->success(['2fa_required' => true, '2fa_token' => $token]);
@@ -1092,7 +1132,7 @@ class subusersRoute
$session = $subuser->generateSession();
$response->success(['session' => $session]);
} else {
$response->error('Invalid password', 400);
$this->subuserAuthFailure();
}
} catch (Exception $e) {
$response->error($e->getMessage(), 500);
@@ -1363,15 +1403,7 @@ class subusersRoute
(int)$phone
);
// Optionally send SMS with setup link
$gatewayAPI = new gatewayapi();
if ($gatewayAPI->isEnabled()) {
$token = $subuser->generateSetupToken();
$link = 'https://truckwash.io/complete-registration?token=' . $token;
$message = 'Tak for din oprettelse af chaufførkonto hos Truck Wash! Klik på linket for at fuldføre registreringen: ' . $link;
$phone_number_array = [(string)$phone_country_code . (string)$phone];
$gatewayAPI->send($phone_number_array, $message);
}
$invite = $this->issueSetupInvite($subuser);
// Create a pending grant request for the company
$subuser_grants_o = new subuser_grants_o();
@@ -1381,7 +1413,7 @@ class subusersRoute
$response->error('Failed to add subuser grant', 500);
}
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber]);
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber, 'invite' => $invite]);
});
}
}
+30 -2
View File
@@ -103,6 +103,9 @@ class userRoute
}
// Check if the required fields are set
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
$response->error('Invalid request body', 400);
}
if (!isset($data['discount'])) {
// Log the incident
(new logs_o())->add('users', 'global', 1, $user->id, 'SET_CUSTOM_PRICE', 'No discount set');
@@ -122,14 +125,38 @@ class userRoute
$response->error('No is_category set', 400);
}
$discount = (int)$data['discount'];
if ($discount < 0 || $discount > 100) {
$response->error('Discount must be between 0 and 100', 400);
}
$is_category = (bool)$data['is_category'];
if ($is_category) {
$object_id = (string)$data['object_id'];
} else {
$object_id = (int)$data['object_id'];
}
$fixed_price_is_set = array_key_exists('fixed_price', $data);
$fixed_price = null;
if ($fixed_price_is_set) {
if ($data['fixed_price'] === null || $data['fixed_price'] === '') {
$fixed_price = null;
} else {
$fixed_price_value = filter_var($data['fixed_price'], FILTER_VALIDATE_INT);
if ($fixed_price_value === false) {
$response->error('Invalid fixed price', 400);
}
$fixed_price = (int)$fixed_price_value;
}
if ($fixed_price !== null && $fixed_price < 0) {
$response->error('Fixed price must be zero or more', 400);
}
if ($is_category && $fixed_price !== null) {
$response->error('Fixed price can only be set for products', 400);
}
} elseif (!$is_category) {
$fixed_price = $targetUser->getProductFixedPrice((int)$object_id);
}
// Set the custom price
$targetUser->setCustomPrice($targetUser->id, $object_id, $discount, $is_category);
$targetUser->setCustomPrice($targetUser->id, $object_id, $discount, $is_category, $fixed_price);
try {
(new economic_v2_versioning_service())->recordDiscountOverrideVersion(
(int)$targetUser->id,
@@ -145,7 +172,8 @@ class userRoute
'route' => '/superuser/user/discounts',
'method' => 'POST',
'actor_user_id' => (int)$user->id,
]
],
$fixed_price
);
} catch (\Throwable $e) {
(new logs_o())->add(
+93 -12
View File
@@ -27,19 +27,28 @@ class usersRoute
(new logs_o())->add('users', 'global', 1, $user->id, 'LIST_USERS', 'Successfully listed users');
// Return the list of users
$users_o = new users_o();
$response->success(
$users_o->parseUsers(
$users_o
->setSearchableFields([
// The fields that can be searched. This would otherwise make it possible to get secret information from the database, simply by searching for it and getting the result count back
'id',
'customer_number',
'group_id',
'display_name',
])
->listObjectsWithPaginationIfSet()
)
$limitedEmployeeListMode = $this->limitedBackofficeEmployeeListMode($users_o);
$users = $users_o
->setSearchableFields([
// The fields that can be searched. This would otherwise make it possible to get secret information from the database, simply by searching for it and getting the result count back
'id',
'customer_number',
'group_id',
'display_name',
])
->listObjectsWithPaginationIfSet(
null,
$limitedEmployeeListMode['filters'],
[],
$limitedEmployeeListMode['additional_where']
);
if ($limitedEmployeeListMode['enabled']) {
$users = $users_o->markLimitedBackofficeManagedUsers($users);
}
$users = $users_o->parseUsers(
$users
);
$response->success($users);
} else {
// Log the incident
(new logs_o())->add('users', 'global', 1, 0, 'LIST_USERS', 'No user found, or invalid session');
@@ -153,6 +162,23 @@ class usersRoute
if (!isset($data['display_name']) || $data['display_name'] === 'null' || $data['display_name'] === '') {
$data['display_name'] = null;
}
$targetUser = (new users_o())->getUserById((int)$data['id']);
if (!$targetUser->exists()) {
$response->error('User not found', 404);
}
if ((new users_o())->isLimitedBackofficeManagedUser((int)$data['id'])) {
$currentCustomerNumber = (string)$targetUser->customer_number->value();
if ((string)$data['customer_number'] !== $currentCustomerNumber) {
$response->error('Limited backoffice managed users cannot change customer number.', 403);
}
if ($data['role'] !== null && (int)$data['role'] !== (int)$targetUser->group_id->value()) {
$response->error('Limited backoffice managed users cannot change role.', 403);
}
$data['role'] = null;
}
// If the role is set, require the edit_user_role permission
if ($data['role']) {
$this->requirePermission('edit_user_role');
@@ -207,4 +233,59 @@ class usersRoute
]
);
}
/**
* @return array{enabled:bool,filters:string|null,additional_where:string|null}
*/
private function limitedBackofficeEmployeeListMode(users_o $users): array
{
$enabled = strtolower((string)($this->fromQuery('include_limited_backoffice_employees') ?? 'false')) === 'true';
$filters = $this->fromQuery('filters');
if ($filters === null || $filters === '') {
return [
'enabled' => false,
'filters' => null,
'additional_where' => null,
];
}
$filterArray = $users->filter_string_to_array($filters);
$customerNumberFilter = $filterArray['customer_number'] ?? null;
$isEmployeeFilter = $customerNumberFilter === '0'
|| $customerNumberFilter === 0
|| (is_array($customerNumberFilter) && in_array('0', $customerNumberFilter, true));
if (!$isEmployeeFilter) {
// When include mode is on but the filter is not a customer_number:0 query,
// pass the original filter through as forced filters so they are not discarded.
// When include mode is off, null causes listObjectsWithPaginationIfSet to fall
// back to reading the filters from the request, which is equivalent.
return [
'enabled' => false,
'filters' => $enabled ? $filters : null,
'additional_where' => null,
];
}
// $activeLimitedEmployeeSubquery is a hardcoded constant with no user input.
$activeLimitedEmployeeSubquery = 'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `deactivated_at` IS NULL';
if (!$enabled) {
// Exclude active limited backoffice employees when the include flag is not set.
return [
'enabled' => false,
'filters' => null,
'additional_where' => '`id` NOT IN (' . $activeLimitedEmployeeSubquery . ')',
];
}
unset($filterArray['customer_number']);
return [
'enabled' => true,
'filters' => $filterArray === [] ? 'id:NOT ZERO' : $users->array_to_filters($filterArray),
'additional_where' => '(`customer_number` = 0 OR `id` IN (' . $activeLimitedEmployeeSubquery . '))',
];
}
}
+5 -3
View File
@@ -132,7 +132,6 @@ class vehiclesRoute
self::requireParameters([
'type',
'reg',
'wash_subscription',
]);
// Determine target customer
@@ -165,11 +164,14 @@ class vehiclesRoute
// Validate the parameters
self::requireType(self::getParameter('reg'), self::type_string());
self::requireType(self::getParameter('type'), self::type_int());
self::requireType(self::getParameter('wash_subscription'), self::type_bool());
$subscription = false;
if (self::isParametersSet(['wash_subscription'])) {
self::requireType(self::getParameter('wash_subscription'), self::type_bool());
$subscription = (bool)self::getParameter('wash_subscription');
}
// Get the parameters
$reg = (string)self::getParameter('reg');
$type = (int)self::getParameter('type');
$subscription = (bool)self::getParameter('wash_subscription');
$reg = trim($reg);
// Create a new vehicle
+1 -1
View File
@@ -48,7 +48,7 @@ class workerRoute
// The time should be from 00:00:00 of the start date to 23:59:59 of the end date
$date_end = date('Y-m-d 23:59:59', strtotime("-1 days")); // Yesterday (Sunday) at 23:59:59
$date_start = date('Y-m-d 00:00:00', strtotime("-$days days")); // $days ago at 00:00:00
$department->sendSlackInternalStatisticNotification($date_start, $date_end, [1,2,3,4,5,6,7]);
$department->sendSlackInternalStatisticNotification($date_start, $date_end);
$response->success(['message' => 'Test message sent to Slack (not really, this is a placeholder).' ]);
exit;
// Configuration

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