Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31214f0af0 | ||
|
|
cd0e0f0e61 | ||
|
|
76dfcd70d1 | ||
|
|
b13abe0d30 | ||
|
|
893ed1bda5 | ||
|
|
90ebec84bf | ||
|
|
bdf2a787d6 | ||
|
|
f8c254607d | ||
|
|
20eb92891a | ||
|
|
4cfe906f55 | ||
|
|
184ea1ca6c | ||
|
|
ae3657e7aa | ||
|
|
54de2e5674 | ||
|
|
eef436d44b | ||
|
|
b7aeb11801 | ||
|
|
d52ceb8513 | ||
|
|
1504f1b116 | ||
|
|
78462f3ae4 | ||
|
|
6ff6ce9b48 | ||
|
|
bc7c0280f2 |
@@ -31,6 +31,9 @@ CONFIG_DB_DATABASE=
|
||||
CONFIG_DB_PORT=3306
|
||||
CONFIG_DB_SSL_MODE=DISABLED
|
||||
|
||||
# Browser origins allowed to call the API. Path-like entries are normalized to origins by the PHP CORS policy.
|
||||
CORS=https://truckwash.io,https://www.truckwash.io,https://api.truckwash.io,https://api.truckwash.io:4433,https://api-v2.truckwash.io,https://web.truckwash.dk,https://api.truckwash.dk,https://truckwash.dk,https://www.truckwash.dk,https://staging.truckwash.io,http://localhost,https://localhost,http://localhost:4433,https://localhost:4433,https://twdev.jeppeb.dk,http://localhost:5173
|
||||
|
||||
# Debug DB credentials (used when CONFIG_DB_TARGET=debug)
|
||||
# Any blank debug value falls back to the live value above.
|
||||
CONFIG_DB_DEBUG_HOST=mysql-debug
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.webp binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.webm binary
|
||||
@@ -20,6 +20,8 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }} # Use PR head when available, otherwise the pushed SHA.
|
||||
fetch-depth: 0 # a full history is required for pull request analysis
|
||||
- name: Mark repository as safe for Git
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
- name: Prepare Qodana cache directories
|
||||
run: |
|
||||
mkdir -p "${RUNNER_TEMP}/qodana/caches"
|
||||
|
||||
@@ -253,3 +253,27 @@ jobs:
|
||||
- name: Tear down local stack
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
|
||||
|
||||
release-manager-gate:
|
||||
name: Release Manager gate
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||
|
||||
steps:
|
||||
- name: Record Release Manager API gate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||
curl --fail --show-error --silent \
|
||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[]}"
|
||||
env:
|
||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_BRANCH: ${{ github.ref_name }}
|
||||
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
|
||||
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ $CONFIG_DB = [
|
||||
$DEBUG = true; // Set to true to enable debugging (Error messages will be shown, and this should never be used in production)
|
||||
$USE_PROD_ECONOMIC_IN_DEBUG = true; // Set to true to use the production economic API in debug mode
|
||||
$ENCRYPTION_KEY = ''; // 44 Characters long encryption key
|
||||
$CORS = '*'; // Set to the domain that should be allowed to access the API e.g. https://example.com
|
||||
$CORS = '*'; // Set to comma-separated allowed origins e.g. https://example.com,https://api-v2.truckwash.io
|
||||
$ECONOMIC_API = [
|
||||
'app_access_grant' => '', // Economic API access grant token (1)
|
||||
'app_access_grant2' => '', // Economic API access grant token (2)
|
||||
|
||||
+209
-5
@@ -78,6 +78,8 @@ tags:
|
||||
description: License plate scanning operations
|
||||
- name: Config
|
||||
description: Module configuration management
|
||||
- name: Release Manager
|
||||
description: Release channel, deployment, and operation management
|
||||
- name: Branding
|
||||
description: Branding options management
|
||||
- name: Roles
|
||||
@@ -4030,13 +4032,15 @@ paths:
|
||||
- name: buttons
|
||||
in: query
|
||||
required: false
|
||||
description: Highlighted button IDs (0-indexed). Accepts CSV, JSON array, or repeated query params.
|
||||
description: Highlighted step tokens in order. Accepts 0-indexed button IDs, "reset", "start", and "program_picker" as CSV, JSON array, or repeated query params.
|
||||
schema:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: array
|
||||
items:
|
||||
type: integer
|
||||
oneOf:
|
||||
- type: integer
|
||||
- type: string
|
||||
- name: current_step
|
||||
in: query
|
||||
required: false
|
||||
@@ -5844,7 +5848,7 @@ paths:
|
||||
reference: {type: string}
|
||||
po: {type: string}
|
||||
pickup: {type: boolean}
|
||||
order_id: {type: integer}
|
||||
order_id: {type: integer, nullable: true}
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
@@ -7701,7 +7705,15 @@ paths:
|
||||
description: Worker status retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
api_commit_sha:
|
||||
type: string
|
||||
description: Running API commit SHA, or unknown when unavailable.
|
||||
|
||||
/worker/debug:
|
||||
get:
|
||||
@@ -8953,6 +8965,10 @@ paths:
|
||||
description: |
|
||||
Send a command (e.g., start, stop, reset) to a self-serve lane.
|
||||
Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here.
|
||||
Operator callers require the base command permission plus the command-specific permission. Authenticated
|
||||
customers with `list_own_department_selfserve_vehicle_conditions` may send `START` on enabled self-serve
|
||||
lanes. Customer `STOP` and property gate commands require the customer's active self-serve wash in the target
|
||||
department.
|
||||
operationId: sendSelfServeLaneCommand
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -9003,6 +9019,9 @@ paths:
|
||||
Updates the set of services that are allowed to be manually activated for a given self-serve lane,
|
||||
derived from the tasks currently shown to the user after answering the self-serve questions.
|
||||
This endpoint does not activate anything by itself; it only sets what is allowed to be activated.
|
||||
Operator callers require `modules_selfserve_lane_services_set_allowed`; authenticated customers with
|
||||
`list_own_department_selfserve_vehicle_conditions` may update their enabled self-serve lane before
|
||||
confirming a wash start.
|
||||
operationId: setSelfServeLaneAllowedServices
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -9446,7 +9465,9 @@ paths:
|
||||
description: |
|
||||
Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services
|
||||
include `MACHINE` (set via `/modules/self-serve/lane/services/allowed`). The relay is never automatically
|
||||
enabled; an explicit call to this endpoint is required.
|
||||
enabled; an explicit call to this endpoint is required. Operator callers require
|
||||
`modules_selfserve_lane_relay_enable_machine`; authenticated customers with
|
||||
`list_own_department_selfserve_vehicle_conditions` may enable it only for their active self-serve wash.
|
||||
Transport follows the department `shelly_transport_mode`; `transport=local` or `transport=gateway`
|
||||
forces local-only diagnostics, and `transport=cloud` forces Shelly cloud.
|
||||
operationId: enableSelfServeLaneMachineRelay
|
||||
@@ -11982,6 +12003,189 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
|
||||
/superuser/releases/operations:
|
||||
get:
|
||||
tags:
|
||||
- Release Manager
|
||||
summary: List release operation runs
|
||||
operationId: listReleaseOperations
|
||||
parameters:
|
||||
- in: query
|
||||
name: channel_id
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: operation_type
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: status
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
responses:
|
||||
'200':
|
||||
description: Release operation runs
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
|
||||
/superuser/releases/operations/{id}:
|
||||
get:
|
||||
tags:
|
||||
- Release Manager
|
||||
summary: Get release operation details
|
||||
operationId: getReleaseOperation
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Release operation details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/superuser/releases/test-runs:
|
||||
post:
|
||||
tags:
|
||||
- Release Manager
|
||||
summary: Run Release Manager diagnostics
|
||||
operationId: runReleaseTest
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
responses:
|
||||
'202':
|
||||
description: Release test operation started
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
|
||||
/superuser/releases/channels/{id}/sync:
|
||||
post:
|
||||
tags:
|
||||
- Release Manager
|
||||
summary: Sync latest branch commits into a release channel
|
||||
operationId: syncReleaseChannel
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'202':
|
||||
description: Channel sync operation started
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
|
||||
/superuser/releases/issues/actions:
|
||||
post:
|
||||
tags:
|
||||
- Release Manager
|
||||
summary: Run a Release Manager issue action
|
||||
operationId: runReleaseIssueAction
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
issue_key:
|
||||
type: string
|
||||
action_id:
|
||||
type: string
|
||||
inputs:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
confirm:
|
||||
type: boolean
|
||||
additionalProperties: true
|
||||
responses:
|
||||
'200':
|
||||
description: Issue action result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
suite="${1:-}"
|
||||
case "$suite" in
|
||||
unit|integration|api|legacy|all)
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 <unit|integration|api|legacy|all>" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
repo_root="$(CDPATH= cd -- "$script_dir/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
|
||||
project_suffix="$(date +%s)-$$"
|
||||
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
|
||||
|
||||
log_dir=".tmp/ci-logs/$suite"
|
||||
mkdir -p "$log_dir"
|
||||
|
||||
env_backup_dir=".tmp/php-ci-env-backup-$project_suffix"
|
||||
mkdir -p "$env_backup_dir"
|
||||
had_env=0
|
||||
had_env_staging=0
|
||||
if [ -f .env ]; then
|
||||
cp .env "$env_backup_dir/env"
|
||||
had_env=1
|
||||
fi
|
||||
if [ -f .env.staging ]; then
|
||||
cp .env.staging "$env_backup_dir/env.staging"
|
||||
had_env_staging=1
|
||||
fi
|
||||
|
||||
cp .github/ci.env .env
|
||||
cp .github/ci.env.staging .env.staging
|
||||
|
||||
collect_logs() {
|
||||
status="$1"
|
||||
if [ "$status" -eq 0 ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$log_dir"
|
||||
docker compose $compose_files ps > "$log_dir/docker-compose-ps.txt" 2>&1 || true
|
||||
docker compose $compose_files logs --no-color > "$log_dir/docker-compose.log" 2>&1 || true
|
||||
docker compose $compose_files cp php1:/var/www/html/build/logs "$log_dir/app-build-logs" >/dev/null 2>&1 || true
|
||||
docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
status="$?"
|
||||
collect_logs "$status"
|
||||
docker compose $compose_files down -v >/dev/null 2>&1 || true
|
||||
if [ "$had_env" -eq 1 ]; then
|
||||
cp "$env_backup_dir/env" .env
|
||||
else
|
||||
rm -f .env
|
||||
fi
|
||||
if [ "$had_env_staging" -eq 1 ]; then
|
||||
cp "$env_backup_dir/env.staging" .env.staging
|
||||
else
|
||||
rm -f .env.staging
|
||||
fi
|
||||
rm -rf "$env_backup_dir"
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
docker compose $compose_files up -d redis mysql-debug php1
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc '
|
||||
set -eu
|
||||
for i in $(seq 1 90); do
|
||||
if MYSQL_PWD="${CONFIG_DB_PASSWORD:-debug_root_password}" mysqladmin \
|
||||
-h "${CONFIG_DB_HOST:-mysql-debug}" \
|
||||
-P "${CONFIG_DB_PORT:-3306}" \
|
||||
-u "${CONFIG_DB_USER:-root}" \
|
||||
ping --silent >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Timed out waiting for mysql-debug" >&2
|
||||
exit 1
|
||||
'
|
||||
|
||||
tar \
|
||||
--exclude='./vendor' \
|
||||
--exclude='./.phpunit.cache' \
|
||||
--exclude='./build/logs' \
|
||||
-C services/nginx/app -cf - . \
|
||||
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
"cd /var/www/html && composer test:ci:$suite"
|
||||
+8903
-2839
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -12120,3 +12120,87 @@
|
||||
[Mon Apr 27 16:09:11 2026] 127.0.0.1:40070 Closing
|
||||
[Mon Apr 27 16:09:12 2026] 127.0.0.1:41964 Accepted
|
||||
[Mon Apr 27 16:09:54 2026] 127.0.0.1:41964 Closing
|
||||
[Tue May 26 09:18:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:35029) started
|
||||
[Tue May 26 09:18:49 2026] 127.0.0.1:35080 Accepted
|
||||
[Tue May 26 09:18:53 2026] 127.0.0.1:35080 Closing
|
||||
[Tue May 26 09:18:53 2026] 127.0.0.1:35088 Accepted
|
||||
[Tue May 26 09:18:56 2026] 127.0.0.1:35088 Closing
|
||||
[Tue May 26 09:18:58 2026] 127.0.0.1:39708 Accepted
|
||||
[Tue May 26 09:19:01 2026] 127.0.0.1:39708 Closing
|
||||
[Tue May 26 09:19:02 2026] 127.0.0.1:39714 Accepted
|
||||
[Tue May 26 09:19:06 2026] 127.0.0.1:39714 Closing
|
||||
[Tue May 26 09:19:05 2026] 127.0.0.1:38256 Accepted
|
||||
[Tue May 26 09:19:08 2026] 127.0.0.1:38256 Closing
|
||||
[Tue May 26 09:19:09 2026] 127.0.0.1:38268 Accepted
|
||||
[Tue May 26 09:19:12 2026] 127.0.0.1:38268 Closing
|
||||
[Tue May 26 09:19:13 2026] 127.0.0.1:37926 Accepted
|
||||
[Tue May 26 09:19:17 2026] 127.0.0.1:37926 Closing
|
||||
[Tue May 26 09:21:24 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33343) started
|
||||
[Tue May 26 09:21:25 2026] 127.0.0.1:53732 Accepted
|
||||
[Tue May 26 09:21:27 2026] 127.0.0.1:53732 Closing
|
||||
[Tue May 26 09:21:27 2026] 127.0.0.1:55472 Accepted
|
||||
[Tue May 26 09:21:30 2026] 127.0.0.1:55472 Closing
|
||||
[Tue May 26 09:21:32 2026] 127.0.0.1:55484 Accepted
|
||||
[Tue May 26 09:21:35 2026] 127.0.0.1:55484 Closing
|
||||
[Tue May 26 09:21:36 2026] 127.0.0.1:44478 Accepted
|
||||
[Tue May 26 09:21:40 2026] 127.0.0.1:44478 Closing
|
||||
[Tue May 26 09:21:41 2026] 127.0.0.1:44486 Accepted
|
||||
[Tue May 26 09:21:46 2026] 127.0.0.1:44486 Closing
|
||||
[Tue May 26 09:21:47 2026] 127.0.0.1:50508 Accepted
|
||||
[Tue May 26 09:21:53 2026] 127.0.0.1:50508 Closing
|
||||
[Tue May 26 09:21:55 2026] 127.0.0.1:42034 Accepted
|
||||
[Tue May 26 09:22:00 2026] 127.0.0.1:42034 Closing
|
||||
[Tue May 26 09:23:59 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41083) started
|
||||
[Tue May 26 09:23:59 2026] 127.0.0.1:45896 Accepted
|
||||
[Tue May 26 09:24:04 2026] 127.0.0.1:45896 Closing
|
||||
[Tue May 26 09:24:04 2026] 127.0.0.1:45908 Accepted
|
||||
[Tue May 26 09:24:07 2026] 127.0.0.1:45908 Closing
|
||||
[Tue May 26 09:25:43 2026] PHP 8.2.15 Development Server (http://127.0.0.1:38485) started
|
||||
[Tue May 26 09:25:43 2026] 127.0.0.1:34288 Accepted
|
||||
[Tue May 26 09:25:47 2026] 127.0.0.1:34288 Closing
|
||||
[Tue May 26 09:25:47 2026] 127.0.0.1:34290 Accepted
|
||||
[Tue May 26 09:25:51 2026] 127.0.0.1:34290 Closing
|
||||
[Tue May 26 09:25:51 2026] 127.0.0.1:41802 Accepted
|
||||
[Tue May 26 09:25:55 2026] 127.0.0.1:41802 Closing
|
||||
[Tue May 26 09:27:23 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37463) started
|
||||
[Tue May 26 09:27:24 2026] 127.0.0.1:43206 Accepted
|
||||
[Tue May 26 09:27:26 2026] 127.0.0.1:43206 Closing
|
||||
[Tue May 26 09:27:26 2026] 127.0.0.1:43212 Accepted
|
||||
[Tue May 26 09:27:33 2026] 127.0.0.1:43212 Closing
|
||||
[Tue May 26 09:27:34 2026] 127.0.0.1:51102 Accepted
|
||||
[Tue May 26 09:27:39 2026] 127.0.0.1:51102 Closing
|
||||
[Tue May 26 09:28:26 2026] PHP 8.2.15 Development Server (http://127.0.0.1:46559) started
|
||||
[Tue May 26 09:28:26 2026] 127.0.0.1:59372 Accepted
|
||||
[Tue May 26 09:28:28 2026] 127.0.0.1:59372 Closing
|
||||
[Tue May 26 09:28:28 2026] 127.0.0.1:59378 Accepted
|
||||
[Tue May 26 09:28:30 2026] 127.0.0.1:59378 Closing
|
||||
[Tue May 26 09:28:31 2026] 127.0.0.1:54846 Accepted
|
||||
[Tue May 26 09:28:31 2026] 127.0.0.1:54846 Closing
|
||||
[Tue May 26 09:28:33 2026] 127.0.0.1:54850 Accepted
|
||||
[Tue May 26 09:28:34 2026] 127.0.0.1:54850 Closing
|
||||
[Tue May 26 09:28:35 2026] 127.0.0.1:54854 Accepted
|
||||
[Tue May 26 09:28:36 2026] 127.0.0.1:54854 Closing
|
||||
[Tue May 26 09:28:36 2026] 127.0.0.1:54864 Accepted
|
||||
[Tue May 26 09:28:41 2026] 127.0.0.1:54864 Closing
|
||||
[Tue May 26 09:28:42 2026] 127.0.0.1:52868 Accepted
|
||||
[Tue May 26 09:28:46 2026] 127.0.0.1:52868 Closing
|
||||
[Tue May 26 09:30:14 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41205) started
|
||||
[Tue May 26 09:30:14 2026] 127.0.0.1:56928 Accepted
|
||||
[Tue May 26 09:30:15 2026] 127.0.0.1:56928 Closing
|
||||
[Tue May 26 09:30:15 2026] 127.0.0.1:56938 Accepted
|
||||
[Tue May 26 09:30:18 2026] 127.0.0.1:56938 Closing
|
||||
[Tue May 26 09:30:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44523) started
|
||||
[Tue May 26 09:30:49 2026] 127.0.0.1:54004 Accepted
|
||||
[Tue May 26 09:30:52 2026] 127.0.0.1:54004 Closing
|
||||
[Tue May 26 09:30:52 2026] 127.0.0.1:38720 Accepted
|
||||
[Tue May 26 09:30:51 2026] 127.0.0.1:38720 Closing
|
||||
[Tue May 26 09:30:53 2026] 127.0.0.1:38734 Accepted
|
||||
[Tue May 26 09:30:56 2026] 127.0.0.1:38734 Closing
|
||||
[Tue May 26 09:30:57 2026] 127.0.0.1:38738 Accepted
|
||||
[Tue May 26 09:31:00 2026] 127.0.0.1:38738 Closing
|
||||
[Tue May 26 09:31:01 2026] 127.0.0.1:38278 Accepted
|
||||
[Tue May 26 09:31:02 2026] 127.0.0.1:38278 Closing
|
||||
[Tue May 26 09:31:03 2026] 127.0.0.1:38284 Accepted
|
||||
[Tue May 26 09:31:07 2026] 127.0.0.1:38284 Closing
|
||||
[Tue May 26 09:31:08 2026] 127.0.0.1:38290 Accepted
|
||||
[Tue May 26 09:31:12 2026] 127.0.0.1:38290 Closing
|
||||
|
||||
@@ -100,6 +100,28 @@ class coolify_api_client
|
||||
}
|
||||
|
||||
public function updateServiceEnvsBulk(string $uuid, array $env): array
|
||||
{
|
||||
if ($env === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->request('PATCH', '/services/' . rawurlencode($uuid) . '/envs/bulk', [
|
||||
'data' => self::bulkEnvData($env),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateApplicationEnvsBulk(string $uuid, array $env): array
|
||||
{
|
||||
if ($env === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->request('PATCH', '/applications/' . rawurlencode($uuid) . '/envs/bulk', [
|
||||
'data' => self::bulkEnvData($env),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function bulkEnvData(array $env): array
|
||||
{
|
||||
$data = [];
|
||||
foreach ($env as $key => $value) {
|
||||
@@ -113,11 +135,7 @@ class coolify_api_client
|
||||
];
|
||||
}
|
||||
|
||||
if ($data === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->request('PATCH', '/services/' . rawurlencode($uuid) . '/envs/bulk', ['data' => $data]);
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function deployResource(string $uuid, bool $force = false): array
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class cors_policy
|
||||
{
|
||||
public const ALLOWED_HEADERS = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
|
||||
public const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
|
||||
public const MAX_AGE_SECONDS = '86400';
|
||||
|
||||
private const REQUIRED_ALLOWED_ORIGINS = [
|
||||
'https://truckwash.io',
|
||||
'https://www.truckwash.io',
|
||||
'https://api.truckwash.io',
|
||||
'https://api.truckwash.io:4433',
|
||||
'https://api-v2.truckwash.io',
|
||||
'https://web.truckwash.dk',
|
||||
'https://api.truckwash.dk',
|
||||
'https://truckwash.dk',
|
||||
'https://www.truckwash.dk',
|
||||
'https://staging.truckwash.io',
|
||||
'http://localhost',
|
||||
'https://localhost',
|
||||
'http://localhost:4433',
|
||||
'https://localhost:4433',
|
||||
'https://twdev.jeppeb.dk',
|
||||
'http://localhost:5173',
|
||||
];
|
||||
|
||||
public static function normalizeOrigin(?string $value): string
|
||||
{
|
||||
$value = trim((string)$value);
|
||||
if ($value === '' || $value === '*') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (preg_match('#^https?://#i', $value) !== 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = parse_url($value);
|
||||
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$scheme = strtolower((string)$parts['scheme']);
|
||||
if (!in_array($scheme, ['http', 'https'], true)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$host = strtolower((string)$parts['host']);
|
||||
$port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';
|
||||
|
||||
return $scheme . '://' . $host . $port;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public static function requiredAllowedOrigins(): array
|
||||
{
|
||||
return self::REQUIRED_ALLOWED_ORIGINS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public static function allowedOrigins(string $corsConfig): array
|
||||
{
|
||||
$origins = [];
|
||||
foreach (self::splitOrigins($corsConfig) as $configuredOrigin) {
|
||||
if ($configuredOrigin === '*') {
|
||||
return ['*'];
|
||||
}
|
||||
|
||||
$origin = self::normalizeOrigin($configuredOrigin);
|
||||
if ($origin !== '') {
|
||||
$origins[$origin] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (self::REQUIRED_ALLOWED_ORIGINS as $requiredOrigin) {
|
||||
$origin = self::normalizeOrigin($requiredOrigin);
|
||||
if ($origin !== '') {
|
||||
$origins[$origin] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($origins);
|
||||
}
|
||||
|
||||
public static function withRequiredOrigins(string $corsConfig): string
|
||||
{
|
||||
$allowedOrigins = self::allowedOrigins($corsConfig);
|
||||
if ($allowedOrigins === ['*']) {
|
||||
return '*';
|
||||
}
|
||||
|
||||
return implode(',', $allowedOrigins);
|
||||
}
|
||||
|
||||
public static function isOriginAllowed(?string $origin, string $corsConfig): bool
|
||||
{
|
||||
$origin = self::normalizeOrigin($origin);
|
||||
if ($origin === '' || $origin === '*') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowedOrigins = self::allowedOrigins($corsConfig);
|
||||
return in_array('*', $allowedOrigins, true) || in_array($origin, $allowedOrigins, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public static function responseHeaders(?string $origin, string $corsConfig): array
|
||||
{
|
||||
$origin = self::normalizeOrigin($origin);
|
||||
if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'Access-Control-Allow-Origin' => $origin,
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
|
||||
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
|
||||
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
|
||||
'Vary' => 'Origin',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{allowed:bool,status:int,headers:array<string,string>,body:string}
|
||||
*/
|
||||
public static function preflightResponse(?string $origin, string $corsConfig): array
|
||||
{
|
||||
$headers = self::responseHeaders($origin, $corsConfig);
|
||||
if ($headers === []) {
|
||||
return [
|
||||
'allowed' => false,
|
||||
'status' => 403,
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'body' => json_encode(['success' => false, 'message' => 'CORS origin not allowed']) ?: '',
|
||||
];
|
||||
}
|
||||
|
||||
$headers['Content-Type'] = 'application/json';
|
||||
return [
|
||||
'allowed' => true,
|
||||
'status' => 200,
|
||||
'headers' => $headers,
|
||||
'body' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public static function applyResponseHeaders(string $corsConfig, ?string $origin = null): bool
|
||||
{
|
||||
$headers = self::responseHeaders($origin ?? ($_SERVER['HTTP_ORIGIN'] ?? ''), $corsConfig);
|
||||
if ($headers === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self::emitHeaders($headers);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $headers
|
||||
*/
|
||||
public static function emitHeaders(array $headers): void
|
||||
{
|
||||
foreach ($headers as $name => $value) {
|
||||
header($name . ': ' . $value, strtolower((string)$name) !== 'vary');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private static function splitOrigins(string $corsConfig): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
array_map('trim', explode(',', $corsConfig)),
|
||||
static fn(string $origin): bool => $origin !== ''
|
||||
));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -118,6 +118,35 @@ class release_manager_schema_bootstrap
|
||||
INDEX idx_release_targets_coolify (coolify_instance_id, coolify_service_uuid)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_auto_sync_events (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
channel_id BIGINT UNSIGNED NOT NULL,
|
||||
app VARCHAR(16) NOT NULL,
|
||||
repository VARCHAR(255) NOT NULL,
|
||||
branch VARCHAR(128) NOT NULL,
|
||||
commit_sha VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
source VARCHAR(64) NULL,
|
||||
workflow_url VARCHAR(512) NULL,
|
||||
gate_operation_id BIGINT UNSIGNED NULL,
|
||||
sync_operation_id BIGINT UNSIGNED NULL,
|
||||
deployment_id BIGINT UNSIGNED NULL,
|
||||
error_message TEXT NULL,
|
||||
metadata_json LONGTEXT NULL,
|
||||
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
gate_passed_at DATETIME NULL,
|
||||
synced_at DATETIME NULL,
|
||||
promoted_at DATETIME NULL,
|
||||
failed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_release_auto_sync_event (channel_id, app, repository, branch, commit_sha),
|
||||
INDEX idx_release_auto_sync_channel_status (channel_id, status, updated_at),
|
||||
INDEX idx_release_auto_sync_gate (gate_operation_id),
|
||||
INDEX idx_release_auto_sync_sync (sync_operation_id),
|
||||
INDEX idx_release_auto_sync_deployment (deployment_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_service_sets (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
channel_id BIGINT UNSIGNED NULL,
|
||||
@@ -157,6 +186,7 @@ class release_manager_schema_bootstrap
|
||||
bundle_id BIGINT UNSIGNED NULL,
|
||||
deployment_kind VARCHAR(32) NOT NULL DEFAULT 'single_app',
|
||||
app VARCHAR(16) NOT NULL,
|
||||
active_channel_app_key VARCHAR(96) NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'coolify',
|
||||
repository VARCHAR(255) NULL,
|
||||
branch VARCHAR(128) NULL,
|
||||
@@ -297,6 +327,47 @@ class release_manager_schema_bootstrap
|
||||
INDEX idx_release_module_health_channel (channel_id, module_key, checked_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_operation_runs (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
operation_type VARCHAR(64) NOT NULL,
|
||||
subject_type VARCHAR(64) NULL,
|
||||
subject_id VARCHAR(128) NULL,
|
||||
channel_id BIGINT UNSIGNED NULL,
|
||||
app VARCHAR(16) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
title VARCHAR(255) NULL,
|
||||
summary TEXT NULL,
|
||||
solution_hint TEXT NULL,
|
||||
actor_user_id INT NULL,
|
||||
context_json LONGTEXT NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_release_operation_runs_channel (channel_id, created_at),
|
||||
INDEX idx_release_operation_runs_subject (subject_type, subject_id, created_at),
|
||||
INDEX idx_release_operation_runs_type_status (operation_type, status),
|
||||
INDEX idx_release_operation_runs_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_operation_steps (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
operation_run_id BIGINT UNSIGNED NOT NULL,
|
||||
step_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
message TEXT NULL,
|
||||
diagnostic TEXT NULL,
|
||||
solution_hint TEXT NULL,
|
||||
context_json LONGTEXT NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_release_operation_steps_run (operation_run_id, id),
|
||||
INDEX idx_release_operation_steps_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_audit_logs (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
channel_id BIGINT UNSIGNED NULL,
|
||||
@@ -321,6 +392,7 @@ class release_manager_schema_bootstrap
|
||||
self::ensureColumn('release_deployments', 'service_set_id', 'BIGINT UNSIGNED NULL AFTER version_id');
|
||||
self::ensureColumn('release_deployments', 'bundle_id', 'BIGINT UNSIGNED NULL AFTER service_set_id');
|
||||
self::ensureColumn('release_deployments', 'deployment_kind', "VARCHAR(32) NOT NULL DEFAULT 'single_app' AFTER bundle_id");
|
||||
self::ensureColumn('release_deployments', 'active_channel_app_key', 'VARCHAR(96) NULL AFTER app');
|
||||
self::ensureColumn('release_timeline_sessions', 'device_type', 'VARCHAR(16) NULL AFTER api_version_id');
|
||||
self::ensureColumn('release_timeline_sessions', 'browser_name', 'VARCHAR(64) NULL AFTER device_type');
|
||||
self::ensureColumn('release_timeline_sessions', 'browser_version', 'VARCHAR(64) NULL AFTER browser_name');
|
||||
@@ -336,9 +408,12 @@ class release_manager_schema_bootstrap
|
||||
self::ensureColumn('release_timeline_sessions', 'last_route_path', 'VARCHAR(255) NULL AFTER api_commit_sha');
|
||||
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_device', 'device_type');
|
||||
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_release', 'frontend_version_label, api_version_label');
|
||||
self::ensureUniqueIndex('release_deployments', 'uniq_release_deployments_active_channel_app', 'active_channel_app_key');
|
||||
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'enabled', 'true', 'bool');
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'github_webhook_secret', '', 'string');
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'release_gate_token', '', 'string');
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'release_gate_required_for_promotion', 'true', 'bool');
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'github_token', '', 'string');
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'github_api_url', 'https://api.github.com', 'string');
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'default_retention_days', '14', 'int');
|
||||
@@ -362,9 +437,12 @@ class release_manager_schema_bootstrap
|
||||
'release_versions',
|
||||
'release_channel_versions',
|
||||
'release_assignments',
|
||||
'release_auto_sync_events',
|
||||
'release_service_sets',
|
||||
'release_deployments',
|
||||
'release_bundles',
|
||||
'release_operation_runs',
|
||||
'release_operation_steps',
|
||||
'release_timeline_sessions',
|
||||
'release_timeline_events',
|
||||
] as $table) {
|
||||
@@ -457,4 +535,23 @@ class release_manager_schema_bootstrap
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
|
||||
}
|
||||
|
||||
private static function ensureUniqueIndex(string $table, string $index, string $columns): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$index = preg_replace('/[^a-zA-Z0-9_]/', '', $index);
|
||||
if ($table === '' || $index === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$indexSql = $db->escape_string($index);
|
||||
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD UNIQUE KEY `$index` ($columns)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,28 @@ class selfserve_schema_bootstrap
|
||||
UNIQUE KEY uniq_selfserve_vhw_department (department_id),
|
||||
INDEX idx_selfserve_vhw_dept_updated (department_id, updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS department_selfserve_path_confirmations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
department_id INT NOT NULL,
|
||||
lane_id INT NULL,
|
||||
vehicle_type_id INT NULL,
|
||||
config_version_id INT NULL,
|
||||
config_source VARCHAR(32) NOT NULL DEFAULT 'draft',
|
||||
path_signature VARCHAR(128) NOT NULL,
|
||||
result_signature VARCHAR(128) NOT NULL,
|
||||
answers_json JSON NOT NULL,
|
||||
result_json JSON NOT NULL,
|
||||
scope_json JSON NULL,
|
||||
confirmed_by INT NULL,
|
||||
confirmed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
stale_reason VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
INDEX idx_selfserve_path_conf_department_scope (department_id, lane_id, vehicle_type_id, config_version_id),
|
||||
INDEX idx_selfserve_path_conf_signature (department_id, config_version_id, path_signature)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $sql) {
|
||||
|
||||
@@ -10,30 +10,18 @@ const WD = __DIR__;
|
||||
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
require_once 'config.php';
|
||||
require_once __DIR__ . '/classes/cors_policy.php';
|
||||
|
||||
/** CORS */
|
||||
$corsAllowedHeaders = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$allowed_origins = array_map('trim', explode(',', (string)($CORS ?? '*')));
|
||||
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
||||
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
||||
header("Access-Control-Allow-Credentials: true");
|
||||
header("Access-Control-Allow-Headers: {$corsAllowedHeaders}");
|
||||
header("Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
||||
}
|
||||
|
||||
// OPTIONS requests are preflight requests for CORS, we can just return a 200 OK response
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
||||
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
||||
header("Access-Control-Allow-Credentials: true");
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
header("Access-Control-Allow-Headers: {$corsAllowedHeaders}");
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(200);
|
||||
$preflight = \classes\cors_policy::preflightResponse($_SERVER['HTTP_ORIGIN'] ?? '', (string)($CORS ?? ''));
|
||||
\classes\cors_policy::emitHeaders($preflight['headers']);
|
||||
http_response_code($preflight['status']);
|
||||
echo $preflight['body'];
|
||||
exit;
|
||||
}
|
||||
}
|
||||
\classes\cors_policy::applyResponseHeaders((string)($CORS ?? ''));
|
||||
/** Debug */
|
||||
if ($DEBUG) {
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace attachments\helpers;
|
||||
class attachment_content
|
||||
{
|
||||
const OTHER_TYPE_WASH_CERTIFICATE = 'WASH_CERTIFICATE';
|
||||
const OTHER_TYPE_SELF_SERVE_WASH = 'SELF_SERVE_WASH';
|
||||
public ?string $image; // Used to store the attachment object name, in the attachment store.
|
||||
public ?string $document; // Used to store the attachment object name, in the attachment store.
|
||||
public ?attachment_relation $relation; // Used to store the attachment relation object.
|
||||
|
||||
@@ -19,6 +19,7 @@ class machine_1 extends dynamicimages_image
|
||||
const IMAGE_BUTTON_HIGHLIGHTED_GREEN = 'machine_1_button_highlighted_green.png';
|
||||
const BUTTON_RESET = 'reset';
|
||||
const BUTTON_START = 'start';
|
||||
const BUTTON_PROGRAM_PICKER = 'program_picker';
|
||||
// Thumb
|
||||
public int $thumb_position = 1; // 0-11 (default: 0 = up = 270 degrees)
|
||||
public int $thumb_size = 1550; // height and width of the thumb
|
||||
@@ -73,7 +74,6 @@ class machine_1 extends dynamicimages_image
|
||||
$this->drawAsset($this->getAsset(self::IMAGE_PANEL_BACKGROUND), 0, 0);
|
||||
$this->drawProgramWheel();
|
||||
$this->drawThumb();
|
||||
$this->drawStepThumb();
|
||||
$deferredStartButtons = $this->drawHighlightedButtonSequence();
|
||||
$this->drawAsset($this->getAsset(self::IMAGE_POWER_BUTTON), 0, 0);
|
||||
$this->drawDeferredHighlightedButtons($deferredStartButtons);
|
||||
@@ -198,7 +198,7 @@ class machine_1 extends dynamicimages_image
|
||||
if (is_string($button)) {
|
||||
$trimmed = trim($button);
|
||||
$specialButton = strtolower($trimmed);
|
||||
if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START) {
|
||||
if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START || $specialButton === self::BUTTON_PROGRAM_PICKER) {
|
||||
return $specialButton;
|
||||
}
|
||||
|
||||
@@ -255,6 +255,10 @@ class machine_1 extends dynamicimages_image
|
||||
|
||||
private function getHighlightedButtonCoordinates(int|string $button): ?array
|
||||
{
|
||||
if ($button === self::BUTTON_PROGRAM_PICKER) {
|
||||
return $this->getProgramPickerStepCoordinates();
|
||||
}
|
||||
|
||||
if ($button === self::BUTTON_RESET) {
|
||||
return [
|
||||
'x' => 1736,
|
||||
@@ -274,6 +278,25 @@ class machine_1 extends dynamicimages_image
|
||||
return is_int($button) ? $this->getRegularButtonCoordinates($button) : null;
|
||||
}
|
||||
|
||||
private function getProgramPickerStepCoordinates(): array
|
||||
{
|
||||
$thumbX = 650;
|
||||
$thumbY = 1290;
|
||||
$stepSize = 350;
|
||||
$baseThumb = $this->getAsset(self::IMAGE_WASH_PROGRAMS_THUMB)->resize($this->calculateThumbWidth($this->thumb_size), $this->thumb_size);
|
||||
$centerX = $thumbX + (int)floor($baseThumb->getWidth() / 2);
|
||||
$centerY = $thumbY + (int)floor($baseThumb->getHeight() / 2);
|
||||
$angle = $this->getRotationThumbPosition();
|
||||
$radius = ($this->thumb_size / 2) - ($stepSize / 2) - 150;
|
||||
$rad = deg2rad($angle);
|
||||
|
||||
return [
|
||||
'x' => $centerX + (int)round($radius * cos($rad)) - (int)floor($stepSize / 2),
|
||||
'y' => $centerY + (int)round($radius * sin($rad)) - (int)floor($stepSize / 2),
|
||||
'size' => $stepSize,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@ class selfserve_lane_command_arguments
|
||||
{
|
||||
public ?string $license_plate = null;
|
||||
public ?int $customer_number = null;
|
||||
public ?int $subuser_id = null;
|
||||
public bool $defer_relay_side_effects = false;
|
||||
|
||||
/**
|
||||
@@ -25,6 +26,12 @@ class selfserve_lane_command_arguments
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setSubuserId(?int $subuser_id): self
|
||||
{
|
||||
$this->subuser_id = $subuser_id !== null && $subuser_id > 0 ? $subuser_id : null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self
|
||||
{
|
||||
$this->defer_relay_side_effects = $defer_relay_side_effects;
|
||||
@@ -40,6 +47,9 @@ class selfserve_lane_command_arguments
|
||||
if (array_key_exists('customer_number', $params)) {
|
||||
$this->setCustomerNumber($params['customer_number']);
|
||||
}
|
||||
if (array_key_exists('subuser_id', $params)) {
|
||||
$this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']);
|
||||
}
|
||||
if (array_key_exists('defer_relay_side_effects', $params)) {
|
||||
$this->setDeferRelaySideEffects(filter_var(
|
||||
$params['defer_relay_side_effects'],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -273,28 +273,19 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return;
|
||||
}
|
||||
|
||||
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE);
|
||||
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE_CLEANER);
|
||||
$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE);
|
||||
$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE_CLEANER);
|
||||
} catch (\Throwable) {
|
||||
// Best effort only; session completion flow must continue.
|
||||
}
|
||||
}
|
||||
|
||||
protected function turnOffRelayIfConfiguredAndOn(selfserve_lane $lane, selfserve_lane_relay $relay): void
|
||||
protected function turnOffRelayIfConfigured(selfserve_lane $lane, selfserve_lane_relay $relay): void
|
||||
{
|
||||
if (!$this->isRelayConfiguredForLane($lane, $relay)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$status = $lane->getRelayStatus($relay);
|
||||
if ((bool)($status['on'] ?? false) !== true) {
|
||||
return;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// If relay status can't be read, still attempt turn-off as best effort.
|
||||
}
|
||||
|
||||
try {
|
||||
$lane->setRelayStatusHard($relay, false);
|
||||
} catch (\Throwable) {
|
||||
@@ -723,14 +714,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
|
||||
protected function enableMachineRelayIfAllowed(array $snapshot, selfserve_wash_sessions_o $session): void
|
||||
{
|
||||
if ((bool)$session->machine_relay_enabled->value() === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
$laneId = (int)$snapshot['lane']['id'];
|
||||
$lane = (new selfserve())->lane($laneId);
|
||||
$this->enableCleanerRelayForStartedWash($lane);
|
||||
|
||||
if ((bool)$session->machine_relay_enabled->value() === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
$session->markRelayEnabled();
|
||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_RELAY_ENABLED, [
|
||||
'lane_id' => $laneId,
|
||||
@@ -841,6 +832,16 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$tasks = $this->buildDebugTasks($snapshot, (array)($candidates['tasks'] ?? []), $lookups, (array)($options['gateway_workspace'] ?? []));
|
||||
$actions = $this->buildDebugActions($snapshot, (array)($candidates['actions'] ?? []), $lookups);
|
||||
$hardware = $this->buildDebugHardware($snapshot, $tasks, (array)($options['gateway_workspace'] ?? []), $lookups, $actions);
|
||||
$dynamicImageButtons = $this->buildDebugDynamicImageButtons($tasks);
|
||||
$decisions = $this->buildDebugDecisions(
|
||||
$questions,
|
||||
$conditions,
|
||||
$rules,
|
||||
$tasks,
|
||||
$actions,
|
||||
(array)($hardware['signal_timeline'] ?? []),
|
||||
$dynamicImageButtons
|
||||
);
|
||||
$recommendations = $this->buildDebugRecommendations($snapshot, $questions, $tasks, $hardware, $conditions, $rules);
|
||||
$summary = $this->buildDebugSummary($snapshot, $recommendations, $hardware);
|
||||
$stages = $this->buildDebugStages($snapshot, $questions, $conditions, $rules, $tasks, $hardware, $summary, $lookups);
|
||||
@@ -871,6 +872,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'rules' => $rules,
|
||||
'tasks' => $tasks,
|
||||
'actions' => $actions,
|
||||
'dynamic_image_buttons' => $dynamicImageButtons,
|
||||
'decisions' => $decisions,
|
||||
'hardware' => $hardware,
|
||||
'signal_timeline' => (array)($hardware['signal_timeline'] ?? []),
|
||||
'graph_annotations' => $annotations,
|
||||
@@ -981,6 +984,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$conditionResults = is_array($snapshot['evaluation_trace']['visibility_condition_results'] ?? null)
|
||||
? (array)$snapshot['evaluation_trace']['visibility_condition_results']
|
||||
: [];
|
||||
$expressionTraces = is_array($snapshot['evaluation_trace']['visibility_expression_traces'] ?? null)
|
||||
? (array)$snapshot['evaluation_trace']['visibility_expression_traces']
|
||||
: [];
|
||||
$items = [];
|
||||
|
||||
foreach ($questions as $question) {
|
||||
@@ -992,20 +998,43 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$visible = isset($visibleIds[$questionId]);
|
||||
$answer = array_key_exists($questionId, $answers) ? $answers[$questionId] : null;
|
||||
$state = !$visible ? 'hidden' : ($answer === null ? 'missing' : 'answered');
|
||||
$label = (string)($question['question'] ?? $this->debugLabel($lookups, 'questions', $questionId, 'Question ' . $questionId));
|
||||
$conditionLabel = $gateId === null ? 'Always visible' : $this->debugLabel($lookups, 'conditions', $gateId, 'Condition ' . $gateId);
|
||||
$gateSatisfied = $gateId === null || (($conditionResults[$gateId] ?? false) === true);
|
||||
$causes = [];
|
||||
if ($gateId !== null) {
|
||||
$causes[] = $this->debugCause(
|
||||
'condition',
|
||||
$gateId,
|
||||
$conditionLabel,
|
||||
true,
|
||||
($conditionResults[$gateId] ?? null),
|
||||
$this->debugExpressionTraceReason($expressionTraces[$gateId] ?? null)
|
||||
);
|
||||
}
|
||||
if ($visible && $answer === null) {
|
||||
$causes[] = $this->debugCause('question', $questionId, $label, 'answered', 'missing', 'Visible question has no simulated answer.');
|
||||
}
|
||||
$reason = $visible
|
||||
? ($answer === null
|
||||
? 'Question ' . $label . ' is visible but has no answer.'
|
||||
: 'Question ' . $label . ' is visible and answered ' . $this->debugValueLabel($answer) . '.')
|
||||
: 'Question ' . $label . ' hidden because visibility condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$gateId] ?? null) . '.';
|
||||
$items[] = [
|
||||
'kind' => 'question',
|
||||
'id' => $questionId,
|
||||
'node_id' => 'question:' . $questionId,
|
||||
'label' => (string)($question['question'] ?? $this->debugLabel($lookups, 'questions', $questionId, 'Question ' . $questionId)),
|
||||
'node_ids' => ['question:' . $questionId],
|
||||
'label' => $label,
|
||||
'visible' => $visible,
|
||||
'state' => $state,
|
||||
'answer' => $answer,
|
||||
'answer_source' => $sources[$questionId] ?? 'missing',
|
||||
'condition_id' => $gateId,
|
||||
'condition' => $gateId === null ? 'Always visible' : $this->debugLabel($lookups, 'conditions', $gateId, 'Condition ' . $gateId),
|
||||
'gate_satisfied' => $gateId === null || (($conditionResults[$gateId] ?? false) === true),
|
||||
'reason' => $visible
|
||||
? ($answer === null ? 'Visible question is missing an answer.' : 'Visible question has an answer.')
|
||||
: 'Visibility condition did not pass.',
|
||||
'condition' => $conditionLabel,
|
||||
'gate_satisfied' => $gateSatisfied,
|
||||
'reason' => $reason,
|
||||
'causes' => $causes,
|
||||
'order_priority' => (int)($question['order_priority'] ?? 0),
|
||||
];
|
||||
}
|
||||
@@ -1036,19 +1065,33 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$actual = $objectType === 'condition' ? ($conditionResults[$objectId] ?? null) : ($answers[$objectId] ?? null);
|
||||
$satisfied = $this->debugRuleSatisfied((string)($rule['type'] ?? ''), $actual);
|
||||
$lookupType = $objectType === 'condition' ? 'conditions' : 'questions';
|
||||
$label = (string)($rule['name'] ?? $this->debugLabel($lookups, 'rules', $ruleId, 'Rule ' . $ruleId));
|
||||
$objectLabel = $this->debugLabel($lookups, $lookupType, $objectId, ucfirst($objectType) . ' ' . $objectId);
|
||||
$expected = $this->debugRuleExpectedValue((string)($rule['type'] ?? ''));
|
||||
$invalidReference = $objectId <= 0 || ($objectType !== 'question' && $objectType !== 'condition');
|
||||
$reason = $invalidReference
|
||||
? 'Rule ' . $label . ' skipped because its referenced object is invalid.'
|
||||
: ($satisfied
|
||||
? 'Rule ' . $label . ' passed because ' . $objectLabel . ' matched ' . $this->debugExpectedLabel($expected) . '.'
|
||||
: 'Rule ' . $label . ' failed because ' . $objectLabel . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.');
|
||||
$items[] = [
|
||||
'kind' => 'rule',
|
||||
'id' => $ruleId,
|
||||
'node_id' => 'rule:' . $ruleId,
|
||||
'node_ids' => ['rule:' . $ruleId],
|
||||
'condition_id' => (int)($rule['condition_id'] ?? 0),
|
||||
'label' => (string)($rule['name'] ?? $this->debugLabel($lookups, 'rules', $ruleId, 'Rule ' . $ruleId)),
|
||||
'label' => $label,
|
||||
'type' => (string)($rule['type'] ?? ''),
|
||||
'object_type' => $objectType,
|
||||
'object_id' => $objectId,
|
||||
'object_label' => $this->debugLabel($lookups, $lookupType, $objectId, ucfirst($objectType) . ' ' . $objectId),
|
||||
'object_label' => $objectLabel,
|
||||
'actual_value' => $actual,
|
||||
'satisfied' => $satisfied,
|
||||
'invalid_reference' => $objectId <= 0 || ($objectType !== 'question' && $objectType !== 'condition'),
|
||||
'reason' => $satisfied ? 'Rule passed for the simulated value.' : 'Rule did not pass for the simulated value.',
|
||||
'invalid_reference' => $invalidReference,
|
||||
'reason' => $reason,
|
||||
'causes' => [
|
||||
$this->debugCause($objectType ?: 'object', $objectId, $objectLabel, $expected, $actual, $invalidReference ? 'Invalid rule reference.' : null),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1082,10 +1125,29 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : [];
|
||||
$expressionTrace = is_array($expressionTraces[$conditionId] ?? null) ? (array)$expressionTraces[$conditionId] : null;
|
||||
$nextFix = $expressionTrace === null ? null : $this->nextFixForExpressionTrace($expressionTrace, $lookups);
|
||||
$label = (string)($condition['name'] ?? $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId));
|
||||
$causes = [];
|
||||
$failedExpressionCause = $expressionTrace === null ? null : $this->debugFailedExpressionCause($expressionTrace, $lookups);
|
||||
if ($failedExpressionCause !== null) {
|
||||
$causes[] = $failedExpressionCause;
|
||||
} elseif (!$result) {
|
||||
foreach ((array)($rulesByCondition[$conditionId] ?? []) as $rule) {
|
||||
if (($rule['satisfied'] ?? false) !== true) {
|
||||
foreach ((array)($rule['causes'] ?? []) as $cause) {
|
||||
if (is_array($cause)) {
|
||||
$causes[] = $cause;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$items[] = [
|
||||
'kind' => 'condition',
|
||||
'id' => $conditionId,
|
||||
'node_id' => 'condition:' . $conditionId,
|
||||
'label' => (string)($condition['name'] ?? $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId)),
|
||||
'node_ids' => ['condition:' . $conditionId],
|
||||
'label' => $label,
|
||||
'result' => $result,
|
||||
'state' => $result ? 'passed' : 'failed',
|
||||
'parent_condition_id' => $this->nullableInt($condition['condition_id'] ?? null),
|
||||
@@ -1098,6 +1160,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'reason' => in_array($conditionId, $cycleIds, true)
|
||||
? 'Condition dependency cycle detected.'
|
||||
: ($expressionTrace['expression']['reason'] ?? ($result ? 'Condition passed.' : 'Condition failed or has no passing rules.')),
|
||||
'causes' => $causes,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1130,6 +1193,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$gateTrace[(int)($trace['task_id'] ?? 0)] = $trace;
|
||||
}
|
||||
}
|
||||
$conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null)
|
||||
? (array)$snapshot['evaluation_trace']['condition_results']
|
||||
: [];
|
||||
$visibleAnswers = is_array($snapshot['debug_candidates']['visible_answers'] ?? null)
|
||||
? (array)$snapshot['debug_candidates']['visible_answers']
|
||||
: (is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : []);
|
||||
$bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace);
|
||||
$items = [];
|
||||
|
||||
@@ -1149,25 +1218,32 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$active = isset($activeIds[$taskId]);
|
||||
$gateType = (string)($trace['gate_type'] ?? $task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value);
|
||||
$gateRefId = $this->nullableInt($trace['gate_ref_id'] ?? $task['gate_ref_id'] ?? $task['condition_id'] ?? null);
|
||||
$gateRefLabel = $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId);
|
||||
$label = (string)($task['task'] ?? $this->debugLabel($lookups, 'tasks', $taskId, 'Task ' . $taskId));
|
||||
$gateSatisfied = ($trace['satisfied'] ?? false) === true;
|
||||
$gateDecision = $this->debugTaskGateDecision($label, $gateType, $gateRefId, $gateRefLabel, $active, $gateSatisfied, $conditionResults, $visibleAnswers);
|
||||
$taskAttachments = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : ($activeAttachments[$taskId] ?? []);
|
||||
$items[] = [
|
||||
'kind' => 'task',
|
||||
'id' => $taskId,
|
||||
'node_id' => 'task:' . $taskId,
|
||||
'label' => (string)($task['task'] ?? $this->debugLabel($lookups, 'tasks', $taskId, 'Task ' . $taskId)),
|
||||
'node_ids' => ['task:' . $taskId],
|
||||
'label' => $label,
|
||||
'description' => (string)($task['description'] ?? ''),
|
||||
'active' => $active,
|
||||
'state' => $active ? 'active' : 'blocked',
|
||||
'gate_type' => $gateType,
|
||||
'gate_ref_id' => $gateRefId,
|
||||
'gate_ref_label' => $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId),
|
||||
'gate_satisfied' => ($trace['satisfied'] ?? false) === true,
|
||||
'gate_ref_label' => $gateRefLabel,
|
||||
'gate_satisfied' => $gateSatisfied,
|
||||
'services' => $services,
|
||||
'buttons' => $this->normalizeButtonList($task['buttons'] ?? null),
|
||||
'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'],
|
||||
'attachments' => $taskAttachments,
|
||||
'relay_bindings' => $bindings,
|
||||
'order_priority' => (int)($task['order_priority'] ?? 0),
|
||||
'reason' => $active ? 'Task gate passed.' : 'Task gate did not pass.',
|
||||
'reason' => $gateDecision['reason'],
|
||||
'causes' => $gateDecision['causes'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1212,42 +1288,59 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$modeMatches = in_array((string)$action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $expectedMode], true);
|
||||
$scopeMatches = true;
|
||||
$scopeReason = null;
|
||||
$scopeCause = null;
|
||||
if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) {
|
||||
$scopeMatches = false;
|
||||
$scopeReason = 'Action department scope does not match the simulated lane.';
|
||||
$scopeCause = $this->debugCause('department', (int)$action['department'], 'Action department scope', $departmentId, (int)$action['department'], $scopeReason);
|
||||
} elseif ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) {
|
||||
$scopeMatches = false;
|
||||
$scopeReason = 'Action lane scope does not match the simulated lane.';
|
||||
$scopeCause = $this->debugCause('lane', (int)$action['lane'], 'Action lane scope', $laneId, (int)$action['lane'], $scopeReason);
|
||||
} elseif ((int)$action['product'] !== 0 && ($vehicleTypeId === null || (int)$action['product'] !== $vehicleTypeId)) {
|
||||
$scopeMatches = false;
|
||||
$scopeReason = 'Action vehicle type scope does not match the simulated vehicle.';
|
||||
$scopeCause = $this->debugCause('vehicle_type', (int)$action['product'], 'Action vehicle type scope', $vehicleTypeId, (int)$action['product'], $scopeReason);
|
||||
} elseif ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== ($machineTypeId ?? 0)) {
|
||||
$scopeMatches = false;
|
||||
$scopeReason = 'Action machine type scope does not match the simulated lane.';
|
||||
$scopeCause = $this->debugCause('machine_type', (int)$action['machine_type_id'], 'Action machine type scope', $machineTypeId, (int)$action['machine_type_id'], $scopeReason);
|
||||
}
|
||||
|
||||
$conditionId = $this->nullableInt($action['condition_id'] ?? null);
|
||||
$conditionSatisfied = $conditionId === null || (($conditionResults[$conditionId] ?? false) === true);
|
||||
$enabled = (bool)($action['enabled'] ?? true);
|
||||
$active = $enabled && $modeMatches && $scopeMatches && $conditionSatisfied;
|
||||
$label = (string)$action['name'];
|
||||
$conditionLabel = $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId);
|
||||
$causes = [];
|
||||
$reason = 'Action would run for this simulator event.';
|
||||
if (!$enabled) {
|
||||
$reason = 'Action is disabled.';
|
||||
$causes[] = $this->debugCause('action', $actionId, $label, true, false, $reason);
|
||||
} elseif (!$modeMatches) {
|
||||
$reason = 'Action wash mode ' . (string)$action['wash_mode'] . ' does not match simulated ' . $expectedMode . ' mode.';
|
||||
$causes[] = $this->debugCause('wash_mode', $actionId, 'Action wash mode', selfserve_studio_actions::MODE_BOTH . ' or ' . $expectedMode, (string)$action['wash_mode'], $reason);
|
||||
} elseif (!$scopeMatches) {
|
||||
$reason = $scopeReason ?? 'Action scope does not match the simulated lane.';
|
||||
if ($scopeCause !== null) {
|
||||
$causes[] = $scopeCause;
|
||||
}
|
||||
} elseif (!$conditionSatisfied) {
|
||||
$reason = 'Action condition gate did not pass.';
|
||||
$reason = 'Action ' . $label . ' skipped because condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$conditionId] ?? null) . '.';
|
||||
$causes[] = $this->debugCause('condition', $conditionId, $conditionLabel, true, ($conditionResults[$conditionId] ?? null), $reason);
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'kind' => 'action',
|
||||
'id' => $actionId,
|
||||
'node_id' => 'action:' . $actionId,
|
||||
'label' => (string)$action['name'],
|
||||
'node_ids' => ['action:' . $actionId],
|
||||
'label' => $label,
|
||||
'active' => $active,
|
||||
'state' => $active ? 'active' : 'skipped',
|
||||
'reason' => $reason,
|
||||
'causes' => $causes,
|
||||
'event' => (string)$action['event'],
|
||||
'event_label' => selfserve_studio_actions::eventLabel((string)$action['event']),
|
||||
'wash_mode' => (string)$action['wash_mode'],
|
||||
@@ -1257,7 +1350,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$action['operation']),
|
||||
'relay_state' => $action['relay_state'],
|
||||
'condition_id' => $conditionId,
|
||||
'condition' => $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId),
|
||||
'condition' => $conditionLabel,
|
||||
'condition_satisfied' => $conditionSatisfied,
|
||||
'scope' => [
|
||||
'department' => (int)$action['department'],
|
||||
@@ -1758,7 +1851,30 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
string $predictedStatus,
|
||||
?string $reason
|
||||
): array {
|
||||
$id = 'signal:' . $sequence;
|
||||
$label = trim($runtimeStage . ' ' . $relayRole . ' ' . $signalType);
|
||||
$targetBinding = $binding['node_id'] ?? null;
|
||||
$nodeIds = [$id];
|
||||
if (isset($payload['action_id'])) {
|
||||
$nodeIds[] = 'action:' . (int)$payload['action_id'];
|
||||
}
|
||||
if (is_string($targetBinding) && $targetBinding !== '') {
|
||||
$nodeIds[] = $targetBinding;
|
||||
}
|
||||
if ($relayId !== null && trim($relayId) !== '') {
|
||||
$nodeIds[] = 'relay:' . $relayId;
|
||||
}
|
||||
$causes = $reason === null ? [] : [
|
||||
$this->debugCause('signal', $id, $label, 'sent', $predictedStatus, $reason),
|
||||
];
|
||||
|
||||
return [
|
||||
'kind' => 'signal',
|
||||
'id' => $id,
|
||||
'node_id' => $id,
|
||||
'node_ids' => array_values(array_unique($nodeIds)),
|
||||
'label' => $label,
|
||||
'state' => $predictedStatus,
|
||||
'sequence' => $sequence,
|
||||
'runtime_stage' => $runtimeStage,
|
||||
'signal_type' => $signalType,
|
||||
@@ -1779,6 +1895,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'predicted_status' => $predictedStatus,
|
||||
'skip_block_reason' => $reason,
|
||||
'reason' => $reason,
|
||||
'causes' => $causes,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2085,6 +2202,333 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
};
|
||||
}
|
||||
|
||||
protected function debugRuleExpectedValue(string $type): mixed
|
||||
{
|
||||
return match (strtoupper(trim($type))) {
|
||||
'IS_TRUE', 'IS_TRUE_OR_ANY_TRUE' => true,
|
||||
'IS_FALSE' => false,
|
||||
'IS_SET' => 'set',
|
||||
'IS_TRUE_OR_NOT_SET' => 'true or missing',
|
||||
'IS_FALSE_OR_NOT_SET' => 'false or missing',
|
||||
default => strtolower(str_replace('_', ' ', trim($type))),
|
||||
};
|
||||
}
|
||||
|
||||
protected function debugExpectedLabel(mixed $expected): string
|
||||
{
|
||||
return $this->debugValueLabel($expected);
|
||||
}
|
||||
|
||||
protected function debugValueLabel(mixed $value): string
|
||||
{
|
||||
if ($value === true) {
|
||||
return 'true';
|
||||
}
|
||||
if ($value === false) {
|
||||
return 'false';
|
||||
}
|
||||
if ($value === null) {
|
||||
return 'missing';
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
return $encoded === false ? 'array' : $encoded;
|
||||
}
|
||||
if ($value instanceof \Stringable) {
|
||||
return (string)$value;
|
||||
}
|
||||
return trim((string)$value) === '' ? 'empty' : (string)$value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
protected function debugCause(string $kind, mixed $id, string $label, mixed $expected, mixed $actual, ?string $reason = null): array
|
||||
{
|
||||
return [
|
||||
'kind' => $kind,
|
||||
'id' => $id,
|
||||
'label' => $label,
|
||||
'expected' => $expected,
|
||||
'actual' => $actual,
|
||||
'expected_label' => $this->debugExpectedLabel($expected),
|
||||
'actual_label' => $this->debugValueLabel($actual),
|
||||
'reason' => $reason,
|
||||
];
|
||||
}
|
||||
|
||||
protected function debugExpressionTraceReason(mixed $trace): ?string
|
||||
{
|
||||
if (!is_array($trace)) {
|
||||
return null;
|
||||
}
|
||||
$expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : (array)$trace;
|
||||
$failed = $this->firstFailedPredicateTrace($expression);
|
||||
if ($failed !== null) {
|
||||
return (string)($failed['reason'] ?? 'Predicate did not pass.');
|
||||
}
|
||||
return isset($trace['reason']) ? (string)$trace['reason'] : (isset($expression['reason']) ? (string)$expression['reason'] : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $trace
|
||||
* @param array<string,mixed> $lookups
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
protected function debugFailedExpressionCause(array $trace, array $lookups): ?array
|
||||
{
|
||||
$expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : $trace;
|
||||
$failed = $this->firstFailedPredicateTrace($expression);
|
||||
if ($failed === null) {
|
||||
if (($expression['result'] ?? true) === false) {
|
||||
return $this->debugCause(
|
||||
'expression',
|
||||
$trace['condition_id'] ?? null,
|
||||
'Condition expression',
|
||||
true,
|
||||
false,
|
||||
(string)($expression['reason'] ?? $trace['reason'] ?? 'Expression did not pass.')
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$subjectType = strtolower((string)($failed['subject_type'] ?? 'question'));
|
||||
$subjectId = (int)($failed['subject_id'] ?? 0);
|
||||
$lookupType = $subjectType === 'condition' ? 'conditions' : 'questions';
|
||||
$label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId);
|
||||
$expected = $this->debugRuleExpectedValue((string)($failed['operator'] ?? 'IS_TRUE'));
|
||||
$actual = $failed['actual_value'] ?? null;
|
||||
$reason = ucfirst($subjectType) . ' ' . $label . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.';
|
||||
|
||||
return $this->debugCause($subjectType ?: 'predicate', $subjectId, $label, $expected, $actual, $reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,bool|null> $conditionResults
|
||||
* @param array<int,bool|null> $visibleAnswers
|
||||
* @return array{reason:string,causes:array<int,array<string,mixed>>}
|
||||
*/
|
||||
protected function debugTaskGateDecision(string $taskLabel, string $gateType, ?int $gateRefId, string $gateRefLabel, bool $active, bool $gateSatisfied, array $conditionResults, array $visibleAnswers): array
|
||||
{
|
||||
$gateType = strtoupper(trim($gateType));
|
||||
if ($gateType === '' || $gateType === selfserve_task_gate_type::ALWAYS->value) {
|
||||
if (!$active && $gateSatisfied) {
|
||||
return [
|
||||
'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ALWAYS passed.',
|
||||
'causes' => [
|
||||
$this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'),
|
||||
],
|
||||
];
|
||||
}
|
||||
return [
|
||||
'reason' => $active
|
||||
? 'Task ' . $taskLabel . ' active because gate ALWAYS is open.'
|
||||
: 'Task ' . $taskLabel . ' blocked because gate ALWAYS expected true, actual false.',
|
||||
'causes' => $active ? [] : [
|
||||
$this->debugCause('gate', null, 'ALWAYS', true, false, 'ALWAYS gate unexpectedly did not pass.'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$actual = $gateType === selfserve_task_gate_type::CONDITION->value
|
||||
? ($gateRefId === null ? null : ($conditionResults[$gateRefId] ?? null))
|
||||
: ($gateRefId === null ? null : ($visibleAnswers[$gateRefId] ?? null));
|
||||
$sourceKind = $gateType === selfserve_task_gate_type::CONDITION->value ? 'condition' : 'question';
|
||||
if (!$active && $gateSatisfied) {
|
||||
return [
|
||||
'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ' . $gateType . ' ' . $gateRefLabel . ' passed.',
|
||||
'causes' => [
|
||||
$this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, 'Gate passed.'),
|
||||
$this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'),
|
||||
],
|
||||
];
|
||||
}
|
||||
$reason = 'Task ' . $taskLabel . ($active ? ' active' : ' blocked') . ' because gate ' . $gateType . ' ' . $gateRefLabel . ' expected true, actual ' . $this->debugValueLabel($actual) . '.';
|
||||
|
||||
return [
|
||||
'reason' => $reason,
|
||||
'causes' => $active ? [] : [
|
||||
$this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, $reason),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $tasks
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
protected function buildDebugDynamicImageButtons(array $tasks): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($tasks as $task) {
|
||||
$taskId = (int)($task['id'] ?? 0);
|
||||
if ($taskId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$taskLabel = (string)($task['label'] ?? $task['task'] ?? ('Task ' . $taskId));
|
||||
$active = (bool)($task['active'] ?? false);
|
||||
foreach ($this->dynamicImageButtonSequenceForTask($task) as $index => $button) {
|
||||
$buttonLabel = $this->debugDynamicImageButtonLabel($button);
|
||||
$id = $taskId . ':' . (int)$index;
|
||||
$nodeId = 'dynamic_image_button:' . $id;
|
||||
$reason = $active
|
||||
? 'Dynamic-image button ' . $buttonLabel . ' is available because task ' . $taskLabel . ' is active.'
|
||||
: 'Dynamic-image button ' . $buttonLabel . ' hidden because task ' . $taskLabel . ' is blocked.';
|
||||
$items[] = [
|
||||
'kind' => 'dynamic_image_button',
|
||||
'id' => $id,
|
||||
'node_id' => $nodeId,
|
||||
'node_ids' => ['task:' . $taskId, $nodeId],
|
||||
'label' => $buttonLabel,
|
||||
'task_id' => $taskId,
|
||||
'task_label' => $taskLabel,
|
||||
'button_index' => (int)$index,
|
||||
'button' => $button,
|
||||
'state' => $active ? 'active' : 'hidden',
|
||||
'reason' => $reason,
|
||||
'causes' => $active ? [] : [
|
||||
$this->debugCause('task', $taskId, $taskLabel, 'active', (string)($task['state'] ?? 'blocked'), (string)($task['reason'] ?? $reason)),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
protected function taskUsesProgramPicker(array $task): bool
|
||||
{
|
||||
if (in_array(
|
||||
'PROGRAM_PICKER',
|
||||
$this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)),
|
||||
true
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array('program_picker', $this->normalizeButtonList($task['buttons'] ?? null), true);
|
||||
}
|
||||
|
||||
protected function isProgramNumberButton(mixed $button): bool
|
||||
{
|
||||
return is_int($button) && $button >= 0 && $button <= 11;
|
||||
}
|
||||
|
||||
protected function dynamicImageButtonSequenceForTask(array $task): array
|
||||
{
|
||||
$buttons = $this->normalizeButtonList($task['buttons'] ?? null);
|
||||
if (!$this->taskUsesProgramPicker($task)) {
|
||||
return $buttons;
|
||||
}
|
||||
|
||||
$sequence = ['program_picker'];
|
||||
foreach ($buttons as $button) {
|
||||
if ($button === 'program_picker' || $this->isProgramNumberButton($button)) {
|
||||
continue;
|
||||
}
|
||||
$sequence[] = $button;
|
||||
}
|
||||
|
||||
return $this->normalizeButtonList($sequence);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $questions
|
||||
* @param array<int,array<string,mixed>> $conditions
|
||||
* @param array<int,array<string,mixed>> $rules
|
||||
* @param array<int,array<string,mixed>> $tasks
|
||||
* @param array<int,array<string,mixed>> $actions
|
||||
* @param array<int,array<string,mixed>> $signals
|
||||
* @param array<int,array<string,mixed>> $dynamicImageButtons
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
protected function buildDebugDecisions(array $questions, array $conditions, array $rules, array $tasks, array $actions, array $signals, array $dynamicImageButtons): array
|
||||
{
|
||||
$decisions = [];
|
||||
foreach ([
|
||||
'question' => $questions,
|
||||
'condition' => $conditions,
|
||||
'rule' => $rules,
|
||||
'task' => $tasks,
|
||||
'action' => $actions,
|
||||
'signal' => $signals,
|
||||
'dynamic_image_button' => $dynamicImageButtons,
|
||||
] as $kind => $items) {
|
||||
foreach ($items as $item) {
|
||||
if (is_array($item)) {
|
||||
$decisions[] = $this->debugDecisionFromItem($kind, $item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $decisions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $item
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
protected function debugDecisionFromItem(string $kind, array $item): array
|
||||
{
|
||||
$nodeIds = [];
|
||||
foreach ((array)($item['node_ids'] ?? []) as $nodeId) {
|
||||
if (is_string($nodeId) && $nodeId !== '') {
|
||||
$nodeIds[] = $nodeId;
|
||||
}
|
||||
}
|
||||
if ($nodeIds === [] && isset($item['node_id']) && is_string($item['node_id']) && $item['node_id'] !== '') {
|
||||
$nodeIds[] = $item['node_id'];
|
||||
}
|
||||
|
||||
$state = (string)($item['state'] ?? '');
|
||||
if ($state === '') {
|
||||
$state = match ($kind) {
|
||||
'condition' => (($item['result'] ?? false) === true ? 'passed' : 'failed'),
|
||||
'rule' => (($item['satisfied'] ?? false) === true ? 'passed' : 'failed'),
|
||||
'task', 'action' => (($item['active'] ?? false) === true ? 'active' : 'skipped'),
|
||||
'signal' => (string)($item['predicted_status'] ?? 'unknown'),
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
$causes = [];
|
||||
foreach ((array)($item['causes'] ?? []) as $cause) {
|
||||
if (is_array($cause)) {
|
||||
$causes[] = $cause;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'kind' => (string)($item['kind'] ?? $kind),
|
||||
'id' => $item['id'] ?? ($item['node_id'] ?? null),
|
||||
'label' => (string)($item['label'] ?? $item['title'] ?? $item['node_id'] ?? $kind),
|
||||
'state' => $state,
|
||||
'reason' => (string)($item['reason'] ?? ''),
|
||||
'node_ids' => array_values(array_unique($nodeIds)),
|
||||
'causes' => $causes,
|
||||
];
|
||||
}
|
||||
|
||||
protected function debugDynamicImageButtonLabel(mixed $button): string
|
||||
{
|
||||
if (is_array($button)) {
|
||||
foreach (['label', 'name', 'title', 'button', 'id', 'value'] as $key) {
|
||||
if (isset($button[$key]) && trim((string)$button[$key]) !== '') {
|
||||
return (string)$button[$key];
|
||||
}
|
||||
}
|
||||
$encoded = json_encode($button, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
return $encoded === false ? 'Button' : $encoded;
|
||||
}
|
||||
$label = trim((string)$button);
|
||||
if (strtolower($label) === 'program_picker') {
|
||||
return 'Program picker';
|
||||
}
|
||||
|
||||
return $label === '' ? 'Button' : $label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $conditions
|
||||
* @param array<int,array<string,mixed>> $rules
|
||||
@@ -2213,6 +2657,26 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return $failed;
|
||||
}
|
||||
}
|
||||
foreach (['when', 'then', 'default'] as $field) {
|
||||
if (!is_array($trace[$field] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
$failed = $this->firstFailedPredicateTrace((array)$trace[$field]);
|
||||
if ($failed !== null) {
|
||||
return $failed;
|
||||
}
|
||||
}
|
||||
foreach (['branches', 'cases'] as $field) {
|
||||
foreach ((array)($trace[$field] ?? []) as $child) {
|
||||
if (!is_array($child)) {
|
||||
continue;
|
||||
}
|
||||
$failed = $this->firstFailedPredicateTrace((array)$child);
|
||||
if ($failed !== null) {
|
||||
return $failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,5 +5,6 @@ namespace modules\selfserve\helpers;
|
||||
enum selfserve_lane_services
|
||||
{
|
||||
case MACHINE; // Relay that controls the machine power
|
||||
case PROGRAM_PICKER; // Relay that controls the machine program picker
|
||||
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ Purpose: manage the task list shown after eligibility evaluation.
|
||||
Notes:
|
||||
|
||||
- Route parameter name is `condition_id`. That value is used as the task gate id.
|
||||
- `services` accepts an array, JSON array string, or comma-separated string. The only current enum case is `MACHINE`.
|
||||
- `services` accepts an array, JSON array string, or comma-separated string. Current enum cases are `MACHINE` and `PROGRAM_PICKER`.
|
||||
- If an active task does not expose `MACHINE`, the relay will not be enabled.
|
||||
|
||||
Typical failures:
|
||||
@@ -356,15 +356,15 @@ Purpose: operational lane control and relay management.
|
||||
| Method | Required params | Permissions | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. |
|
||||
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`. |
|
||||
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed` | Writes allowed service names to the lane cache. |
|
||||
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission, or customer `list_own_department_selfserve_vehicle_conditions` for scoped `START`, scoped `STOP`, and property gate commands | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`, `OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`. Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer's active wash in the lane department. |
|
||||
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed`, or customer `list_own_department_selfserve_vehicle_conditions` on an enabled self-serve lane | Writes allowed service names to the lane cache. This is still read-from-visible-tasks only; it does not activate relays. |
|
||||
| `GET /modules/self-serve/lane/relay/machine_program_picker/status` | `lane_id` | `modules_selfserve_lane_relay_machine_program_picker_status_view` | Reads the Shelly MACHINE_PROGRAM_PICKER relay state (`on`/`off`) for the lane. |
|
||||
| `POST /modules/self-serve/lane/relay/machine_program_picker/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_program_picker_status_set` | Sets Shelly MACHINE_PROGRAM_PICKER relay state directly (`on=true/false`) and returns updated status. |
|
||||
| `GET /modules/self-serve/lane/relay/machine_cleaner/status` | `lane_id` | `modules_selfserve_lane_relay_machine_cleaner_status_view` | Reads the Shelly MACHINE_CLEANER relay state (`on`/`off`) for the lane. |
|
||||
| `POST /modules/self-serve/lane/relay/machine_cleaner/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_cleaner_status_set` | Sets Shelly MACHINE_CLEANER relay state directly (`on=true/false`) and returns updated status. |
|
||||
| `GET /modules/self-serve/lane/relay/machine/status` | `lane_id` | `modules_selfserve_lane_relay_machine_status_view` | Reads the Shelly MACHINE relay state (`on`/`off`) for the lane. |
|
||||
| `POST /modules/self-serve/lane/relay/machine/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_status_set` | Sets Shelly MACHINE relay state directly (`on=true/false`) and returns updated status. |
|
||||
| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine` | Manual enable, still gated by allowed services. |
|
||||
| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine`, or customer `list_own_department_selfserve_vehicle_conditions` with an active wash in the lane department | Manual enable, still gated by allowed services. Customer flow calls this only after `START` and only when `MACHINE` is allowed. |
|
||||
| `POST /modules/self-serve/lane/force/machine/enable` | `lane_id`, optional `duration`, optional `license_plate` | `modules_selfserve_lane_force_machine_enable` | Bypasses service gating and marks the lane as in wash. |
|
||||
| `POST /modules/self-serve/lane/force/machine/disable` | `lane_id`, optional `license_plate` | `modules_selfserve_lane_force_machine_disable` | Keeps the lane in wash but turns the machine relay off. |
|
||||
|
||||
@@ -382,6 +382,12 @@ STOP flow details:
|
||||
- Unless bypass is enabled, the lane customer number must match the current authenticated user's customer number.
|
||||
- STOP calls `invoice()`, opens the exit port, turns off the machine relay if self-serve is enabled for the department, completes the latest open self-serve session, and then resets the lane.
|
||||
|
||||
Customer start-wash release checklist:
|
||||
|
||||
- Canary hardware validation must use the department and lane configured for the live Playwright/release credentials. Record the exact `department_id` and `lane_id` in the release notes before the live run.
|
||||
- Verify the self-serve module is enabled, department self-serve is enabled, the target lane has `selfserve_enabled=1`, relays and property gates are bound, minute billing product is configured, and the machine task exposes the `MACHINE` service before promoting canary.
|
||||
- Validate one supervised real-lane manual wash and, when configured, one machine wash before stable promotion. Confirm no relay changes before customer confirmation, active wash restore works across reloads, property gates open only during the active wash, `STOP` completes the session, and billing/order linkage is present.
|
||||
|
||||
Typical failures:
|
||||
|
||||
- `400` invalid parameters
|
||||
@@ -480,7 +486,7 @@ Important enums:
|
||||
- `selfserve_lane_command`: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`
|
||||
- `selfserve_lane_status`: `AVAILABLE`, `OCCUPIED`, `RESERVED`, `FAULT`, `MAINTENANCE`, `CLOSED`
|
||||
- `selfserve_lane_state`: `IDLE`, `IN_WASH`, relay states, gate states, and fault states
|
||||
- `selfserve_lane_services`: currently only `MACHINE`
|
||||
- `selfserve_lane_services`: currently `MACHINE` and `PROGRAM_PICKER`
|
||||
|
||||
### Machine Type And Wash Session Objects
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ trait selfserve_lane_cache_t
|
||||
{
|
||||
const CACHE_SELFSERVE_PREFIX = 'selfserve_lane_';
|
||||
const CACHE_SELFSERVE_LANE_KEY_STATUS = self::CACHE_SELFSERVE_PREFIX . 'status';
|
||||
const CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT = self::CACHE_SELFSERVE_PREFIX . 'status_audit';
|
||||
const CACHE_SELFSERVE_LANE_KEY_STATE = self::CACHE_SELFSERVE_PREFIX . 'state';
|
||||
const CACHE_SELFSERVE_LANE_KEY_MODE = self::CACHE_SELFSERVE_PREFIX . 'mode';
|
||||
const CACHE_SELFSERVE_LANE_KEY_WASH_START_TIME = self::CACHE_SELFSERVE_PREFIX . 'wash_start_time';
|
||||
|
||||
@@ -567,7 +567,7 @@ trait selfserve_lane_command_t
|
||||
// Log the lane stop event
|
||||
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
|
||||
// Invoice the customer
|
||||
$this->invoice();
|
||||
$this->invoice($arguments);
|
||||
// Only bill the machine wash product when the physical machine start signal was recorded.
|
||||
$this->addVehicleTypeProductToInvoiceIfNeeded($machine_start_triggered);
|
||||
// Finalize any active self-serve wash session for this lane
|
||||
|
||||
@@ -4,15 +4,24 @@ namespace modules\selfserve\traits;
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_status.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_mode.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_lane.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
|
||||
require_once WD . '/modules/attachments/helpers/attachment_content.php';
|
||||
require_once WD . '/objects/selfserve_wash_sessions_o.php';
|
||||
require_once WD . '/objects/subusers_o.php';
|
||||
|
||||
use classes\selfserve;
|
||||
use classes\economic;
|
||||
use Exception;
|
||||
use attachments\helpers\attachment_content;
|
||||
use modules\selfserve\classes\selfserve_lane_command_arguments;
|
||||
use modules\selfserve\classes\selfserve_lane;
|
||||
use modules\selfserve\helpers\selfserve_lane_mode;
|
||||
use modules\selfserve\helpers\selfserve_lane_status;
|
||||
use objects\customer_vehicles_o;
|
||||
use objects\order_items_o;
|
||||
use objects\orders_o;
|
||||
use objects\selfserve_wash_sessions_o;
|
||||
use objects\subusers_o;
|
||||
|
||||
trait selfserve_lane_invoice_t
|
||||
{
|
||||
@@ -102,7 +111,7 @@ trait selfserve_lane_invoice_t
|
||||
* @return bool True on success, false on failure
|
||||
* @throws Exception if lane ID is not set, lane is not occupied, customer number or license plate is not set, or product ID is not set
|
||||
*/
|
||||
public function invoice(): bool
|
||||
public function invoice(?selfserve_lane_command_arguments $arguments = null): bool
|
||||
{
|
||||
$this->last_invoice_order_id = null;
|
||||
|
||||
@@ -115,12 +124,10 @@ trait selfserve_lane_invoice_t
|
||||
$included_minutes = $this->resolveIncludedMinutesForBilling();
|
||||
$billable_minutes = $this->calculateBillableMinutes($elapsed_minutes, $included_minutes);
|
||||
|
||||
if ($billable_minutes <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$order = $this->createInvoiceOrderContext();
|
||||
if ($billable_minutes > 0) {
|
||||
$order = $this->createInvoiceOrderContext($arguments);
|
||||
$this->billable_minutes_order_item = $this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -212,10 +219,13 @@ trait selfserve_lane_invoice_t
|
||||
return max(0, $elapsed_minutes - $included_minutes);
|
||||
}
|
||||
|
||||
protected function createInvoiceOrderContext(): orders_o
|
||||
protected function createInvoiceOrderContext(?selfserve_lane_command_arguments $arguments = null): orders_o
|
||||
{
|
||||
$billing_customer_number = $this->getCustomerNumber();
|
||||
$draft_customer_number = (new economic())->getTransactionDraftCustomerNumber();
|
||||
$order_customer_number = $draft_customer_number ?? $billing_customer_number;
|
||||
$order = (new orders_o())->add(
|
||||
$this->getCustomerNumber(),
|
||||
$order_customer_number,
|
||||
self::INVOICE_SYSTEM_USER_ID,
|
||||
'',
|
||||
'',
|
||||
@@ -224,10 +234,101 @@ trait selfserve_lane_invoice_t
|
||||
);
|
||||
$order->lane->set($this->id);
|
||||
$this->last_invoice_order_id = (int)$order->id;
|
||||
$this->attachSelfServeMetadataToOrder($order, $billing_customer_number, $draft_customer_number, $arguments);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
protected function attachSelfServeMetadataToOrder(
|
||||
orders_o $order,
|
||||
int $billing_customer_number,
|
||||
?int $draft_customer_number,
|
||||
?selfserve_lane_command_arguments $arguments = null
|
||||
): void {
|
||||
try {
|
||||
$order->addAttachment(
|
||||
(new attachment_content())->setOther(
|
||||
$this->buildSelfServeOrderAttachmentPayload($billing_customer_number, $draft_customer_number, $arguments)
|
||||
)
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Metadata attachments must not block billing; the order itself is the source of record.
|
||||
}
|
||||
}
|
||||
|
||||
protected function buildSelfServeOrderAttachmentPayload(
|
||||
int $billing_customer_number,
|
||||
?int $draft_customer_number,
|
||||
?selfserve_lane_command_arguments $arguments = null
|
||||
): array {
|
||||
$session = $this->findOpenSelfServeSessionForAttachment($billing_customer_number);
|
||||
$subuser_id = $arguments?->subuser_id;
|
||||
|
||||
return [
|
||||
'type' => attachment_content::OTHER_TYPE_SELF_SERVE_WASH,
|
||||
'source' => 'selfserve',
|
||||
'customer_number' => $billing_customer_number,
|
||||
'draft_customer_number' => $draft_customer_number,
|
||||
'subuser_id' => $subuser_id,
|
||||
'subuser' => $this->formatSelfServeAttachmentSubuser($subuser_id),
|
||||
'session_id' => $session?->id,
|
||||
'lane_id' => (int)$this->id,
|
||||
'department_id' => (int)$this->department_lane->department->value(),
|
||||
'license_plate' => (string)$this->getLicensePlate(),
|
||||
'lane_status' => $this->getLaneStatus()->name,
|
||||
'lane_mode' => $this->getLaneMode()->name,
|
||||
'wash_start_time' => (int)$this->getWashStartTime(),
|
||||
'elapsed_wash_time_seconds' => (int)$this->getElapsedWashTime(),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function findOpenSelfServeSessionForAttachment(int $billing_customer_number): ?selfserve_wash_sessions_o
|
||||
{
|
||||
$license_plate = trim((string)$this->getLicensePlate());
|
||||
if ($license_plate === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLaneAndReg(
|
||||
(int)$this->id,
|
||||
selfserve::standardize_registration($license_plate),
|
||||
$billing_customer_number > 0 ? $billing_customer_number : null
|
||||
);
|
||||
return $session->exists() ? $session : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function formatSelfServeAttachmentSubuser(?int $subuser_id): ?array
|
||||
{
|
||||
if ($subuser_id === null || $subuser_id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$subuser = (new subusers_o())->select($subuser_id);
|
||||
if (!$subuser->exists()) {
|
||||
return [
|
||||
'id' => $subuser_id,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)$subuser->id,
|
||||
'name' => $subuser->name->value(),
|
||||
'username' => $subuser->username->value(),
|
||||
'email' => $subuser->email->value(),
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
return [
|
||||
'id' => $subuser_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
protected function addMinuteBillingLine(int $order_id, int $product_id, int $quantity): order_items_o
|
||||
{
|
||||
return (new order_items_o())->addItemToOrder(
|
||||
|
||||
@@ -87,4 +87,44 @@ trait selfserve_lane_status_t
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setLaneStatusAudit(?array $audit): self
|
||||
{
|
||||
if ($audit === null) {
|
||||
$this->clearLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT);
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->setLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT, [
|
||||
'modified_at' => isset($audit['modified_at']) ? (string)$audit['modified_at'] : date(DATE_ATOM),
|
||||
'modified_by_user_id' => isset($audit['modified_by_user_id']) ? (int)$audit['modified_by_user_id'] : null,
|
||||
'modified_by_name' => isset($audit['modified_by_name']) ? (string)$audit['modified_by_name'] : null,
|
||||
]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLaneStatusAudit(): ?array
|
||||
{
|
||||
$audit = $this->getLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT);
|
||||
if (!is_array($audit)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$modified_at = isset($audit['modified_at']) ? trim((string)$audit['modified_at']) : '';
|
||||
$modified_by_name = isset($audit['modified_by_name']) ? trim((string)$audit['modified_by_name']) : '';
|
||||
$modified_by_user_id = isset($audit['modified_by_user_id']) && is_numeric($audit['modified_by_user_id'])
|
||||
? (int)$audit['modified_by_user_id']
|
||||
: null;
|
||||
|
||||
if ($modified_at === '' && $modified_by_name === '' && $modified_by_user_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'modified_at' => $modified_at !== '' ? $modified_at : null,
|
||||
'modified_by_user_id' => $modified_by_user_id,
|
||||
'modified_by_name' => $modified_by_name !== '' ? $modified_by_name : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,10 @@ class department_lanes_o extends db
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$status = (string)$this->getLaneStatus()->name;
|
||||
$machine_status_audit = $this->getMachineStatusAudit();
|
||||
$selfserve_configuration_warnings = $this->getSelfServeConfigurationWarnings();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'department' => (int)$this->department->value(),
|
||||
@@ -143,13 +147,76 @@ class department_lanes_o extends db
|
||||
'machine_type_id' => (function($v){ return $v === null ? null : (int)$v; })($this->machine_type_id->value()),
|
||||
'selfserve_enabled' => $this->isSelfServeEnabled(),
|
||||
// Status of the lane
|
||||
'status' => (string)$this->getLaneStatus()->name,
|
||||
'status' => $status,
|
||||
'machine_status_enabled' => self::isOperationalStatusName($status),
|
||||
'machine_status_audit' => $machine_status_audit,
|
||||
'machine_status_modified_at' => $machine_status_audit['modified_at'] ?? null,
|
||||
'machine_status_modified_by' => $machine_status_audit['modified_by_name'] ?? null,
|
||||
'machine_status_modified_by_user_id' => $machine_status_audit['modified_by_user_id'] ?? null,
|
||||
'selfserve_configured' => $selfserve_configuration_warnings === [],
|
||||
'dognvask_configured' => $selfserve_configuration_warnings === [],
|
||||
'dognvask_configuration_warnings' => $selfserve_configuration_warnings,
|
||||
// Timestamps
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
|
||||
private function getMachineStatusAudit(): ?array
|
||||
{
|
||||
try {
|
||||
$lane = (new selfserve())->lane((int)$this->id);
|
||||
if (!method_exists($lane, 'getLaneStatusAudit')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$audit = $lane->getLaneStatusAudit();
|
||||
return is_array($audit) ? $audit : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getSelfServeConfigurationWarnings(): array
|
||||
{
|
||||
self::requireSelected();
|
||||
|
||||
$required_fields = [
|
||||
'relay_in_id' => 'Indgangsrelæ',
|
||||
'relay_out_id' => 'Udgangsrelæ',
|
||||
'relay_machine_id' => 'Maskinrelæ',
|
||||
'relay_machine_program_picker_id' => 'Programvælgerrelæ',
|
||||
'relay_machine_cleaner_id' => 'Vaskerelæ',
|
||||
'dynamic_image_id' => 'Maskinstatusbillede',
|
||||
'machine_type_id' => 'Maskintype',
|
||||
];
|
||||
|
||||
$warnings = [];
|
||||
foreach ($required_fields as $field => $label) {
|
||||
if ($this->hasConfiguredFieldValue($field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$warnings[] = [
|
||||
'field' => $field,
|
||||
'label' => $label,
|
||||
'message' => $label . ' mangler',
|
||||
];
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
public function isSelfServeConfigured(): bool
|
||||
{
|
||||
return $this->getSelfServeConfigurationWarnings() === [];
|
||||
}
|
||||
|
||||
public static function isOperationalStatusName(string $status): bool
|
||||
{
|
||||
return in_array(strtoupper(trim($status)), ['AVAILABLE', 'OCCUPIED', 'RESERVED'], true);
|
||||
}
|
||||
|
||||
public function isSelfServeEnabled(): bool
|
||||
{
|
||||
self::requireSelected();
|
||||
@@ -184,6 +251,29 @@ class department_lanes_o extends db
|
||||
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private function hasConfiguredFieldValue(string $field): bool
|
||||
{
|
||||
if (!isset($this->{$field}) || !is_object($this->{$field}) || !method_exists($this->{$field}, 'value')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$value = $this->{$field}->value();
|
||||
if ($value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$value = trim($value);
|
||||
return $value !== '' && $value !== '0' && strtolower($value) !== 'null';
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int)$value > 0;
|
||||
}
|
||||
|
||||
return (bool)$value;
|
||||
}
|
||||
|
||||
public static function disableSelfServeRelaysBestEffort(int $lane_id): void
|
||||
{
|
||||
if ($lane_id <= 0) {
|
||||
|
||||
@@ -386,7 +386,7 @@ class department_selfserve_tasks_o extends db
|
||||
if (is_string($btn)) {
|
||||
$trimmed = trim($btn);
|
||||
$specialButton = strtolower($trimmed);
|
||||
if ($specialButton === 'reset' || $specialButton === 'start') {
|
||||
if ($specialButton === 'reset' || $specialButton === 'start' || $specialButton === 'program_picker') {
|
||||
$ids[] = $specialButton;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ class products_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi';
|
||||
|
||||
/**
|
||||
* The name of the product
|
||||
* @var object_property
|
||||
@@ -214,7 +217,7 @@ class products_o extends db
|
||||
'piktogram' => $this->piktogram->value(),
|
||||
'economic_product_id' => $this->economic_product_id->value(),
|
||||
'apply_category_discount' => (bool)$this->apply_category_discount->value(),
|
||||
'requires_note' => (bool)$this->requires_note->value(),
|
||||
'requires_note' => $this->requiresOrderItemNote(),
|
||||
'is_wash' => (bool)$this->is_wash->value(),
|
||||
'display_in_booking_form' => (bool)$this->display_in_booking_form->value(),
|
||||
'order_priority' => (int)$this->order_priority->value(),
|
||||
@@ -224,6 +227,28 @@ class products_o extends db
|
||||
];
|
||||
}
|
||||
|
||||
public static function productDataRequiresOrderItemNote(array $product): bool
|
||||
{
|
||||
if ((bool)($product['requires_note'] ?? false)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((int)($product['id'] ?? 0) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_ID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return trim((string)($product['name'] ?? '')) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME;
|
||||
}
|
||||
|
||||
public function requiresOrderItemNote(): bool
|
||||
{
|
||||
return self::productDataRequiresOrderItemNote([
|
||||
'id' => $this->id,
|
||||
'name' => (string)$this->name->value(),
|
||||
'requires_note' => (bool)$this->requires_note->value(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply department pricing to a list of products
|
||||
* @param array $products
|
||||
|
||||
@@ -22,28 +22,57 @@ class subuser_grants_o extends db
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
const defaultPermissions = [
|
||||
subusers_permission_node_key::VEHICLES_LIST,
|
||||
subusers_permission_node_key::SELFSERVE_ADD,
|
||||
subusers_permission_node_key::BOOKINGS_LIST,
|
||||
subusers_permission_node_key::BOOKINGS_ADD,
|
||||
subusers_permission_node_key::BOOKINGS_EDIT,
|
||||
subusers_permission_node_key::BOOKINGS_DELETE,
|
||||
'VEHICLES_LIST',
|
||||
'SELFSERVE_ADD',
|
||||
'BOOKINGS_LIST',
|
||||
'BOOKINGS_ADD',
|
||||
'BOOKINGS_EDIT',
|
||||
'BOOKINGS_DELETE',
|
||||
];
|
||||
|
||||
private static function normalizePermissionsValue(mixed $raw): array
|
||||
public static function normalizePermissionsValue(mixed $raw): array
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
if ($raw === null || $raw === '' || $raw === false || $raw === 0 || $raw === '0') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($raw instanceof subusers_permission_node_key) {
|
||||
return [$raw->name];
|
||||
}
|
||||
|
||||
if (is_array($raw)) {
|
||||
return array_values(array_filter($raw, static fn ($permission) => is_string($permission) && trim($permission) !== ''));
|
||||
$permissions = [];
|
||||
$permissionCandidates = array_is_list($raw)
|
||||
? $raw
|
||||
: array_keys(array_filter($raw, static fn ($enabled): bool => (bool)$enabled));
|
||||
|
||||
foreach ($permissionCandidates as $permission) {
|
||||
if ($permission instanceof subusers_permission_node_key) {
|
||||
$permission = $permission->name;
|
||||
}
|
||||
|
||||
if (!is_string($permission)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$permission = strtoupper(trim($permission));
|
||||
if ($permission !== '' && subusers_permission_node_key::tryFrom($permission) !== null) {
|
||||
$permissions[] = $permission;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($permissions));
|
||||
}
|
||||
|
||||
if (is_string($raw)) {
|
||||
$decoded = json_decode($raw, true);
|
||||
if (is_array($decoded)) {
|
||||
return array_values(array_filter($decoded, static fn ($permission) => is_string($permission) && trim($permission) !== ''));
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
return self::normalizePermissionsValue($decoded);
|
||||
}
|
||||
|
||||
$permission = strtoupper(trim($raw));
|
||||
if (subusers_permission_node_key::tryFrom($permission) !== null) {
|
||||
return [$permission];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,12 +132,13 @@ class subuser_grants_o extends db
|
||||
public function add(int $billing_customer_number, int $subuser, bool $enabled, ?string $note, ?array $permissions = self::defaultPermissions): subuser_grants_o
|
||||
{
|
||||
global $db;
|
||||
$permissions = self::normalizePermissionsValue($permissions);
|
||||
$tmp = $this->add_object([
|
||||
'billing_customer_number' => (int)$billing_customer_number,
|
||||
'subuser' => (int)$subuser,
|
||||
'enabled' => (bool)$enabled,
|
||||
'note' => !empty($note) ? $db->escape_string($note) : null,
|
||||
'permissions' => !empty($permissions) ? json_encode($permissions) : json_encode([]),
|
||||
'permissions' => json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
$this->id = (int)$tmp;
|
||||
$this->getObjectProperties();
|
||||
|
||||
@@ -15,6 +15,11 @@ class subusers_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public const PASSWORD_MIN_LENGTH = 8;
|
||||
public const PASSWORD_MAX_LENGTH = 255;
|
||||
public const PASSWORD_PATTERN = '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/';
|
||||
public const PASSWORD_COMPLEXITY_MESSAGE = 'Password must contain at least one uppercase letter, one lowercase letter, and one number';
|
||||
|
||||
public object_property $username;
|
||||
public object_property $password;
|
||||
public object_property $name;
|
||||
@@ -62,6 +67,23 @@ class subusers_o extends db
|
||||
return (bool)$this->two_factor_enabled->value();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function assertValidPassword(string $password): void
|
||||
{
|
||||
if (
|
||||
strlen($password) < self::PASSWORD_MIN_LENGTH
|
||||
|| strlen($password) > self::PASSWORD_MAX_LENGTH
|
||||
|| !preg_match(self::PASSWORD_PATTERN, $password)
|
||||
) {
|
||||
throw new Exception(
|
||||
'Password must be between ' . self::PASSWORD_MIN_LENGTH . ' and ' . self::PASSWORD_MAX_LENGTH
|
||||
. ' characters long and contain at least one uppercase letter, one lowercase letter, and one number.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -103,11 +125,9 @@ class subusers_o extends db
|
||||
{
|
||||
global $db, $response;
|
||||
try {
|
||||
if (!empty($password)) {
|
||||
// Validate the password (at least 8 characters, at least one uppercase letter, at least one lowercase letter, at least one number)
|
||||
if (!preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/', $password)) {
|
||||
throw new Exception('Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number.');
|
||||
}
|
||||
$passwordWasProvided = !empty($password);
|
||||
if ($passwordWasProvided) {
|
||||
self::assertValidPassword($password);
|
||||
// Hash the password
|
||||
$password = password_hash($password, PASSWORD_DEFAULT);
|
||||
}
|
||||
@@ -163,6 +183,7 @@ class subusers_o extends db
|
||||
public function setPassword(string $password): self
|
||||
{
|
||||
self::requireSelected();
|
||||
self::assertValidPassword($password);
|
||||
$this->password->set((string)password_hash($password, PASSWORD_DEFAULT));
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ tags:
|
||||
description: License plate scanning operations
|
||||
- name: Config
|
||||
description: Module configuration management
|
||||
- name: Release Manager
|
||||
description: Release channel, deployment, and operation management
|
||||
- name: Branding
|
||||
description: Branding options management
|
||||
- name: Roles
|
||||
@@ -4071,13 +4073,15 @@ paths:
|
||||
- name: buttons
|
||||
in: query
|
||||
required: false
|
||||
description: Highlighted button IDs (0-indexed). Accepts CSV, JSON array, or repeated query params.
|
||||
description: Highlighted step tokens in order. Accepts 0-indexed button IDs, "reset", "start", and "program_picker" as CSV, JSON array, or repeated query params.
|
||||
schema:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: array
|
||||
items:
|
||||
type: integer
|
||||
oneOf:
|
||||
- type: integer
|
||||
- type: string
|
||||
- name: current_step
|
||||
in: query
|
||||
required: false
|
||||
@@ -5456,7 +5460,7 @@ paths:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Bulk save all-in-one self-serve studio graph changes
|
||||
description: Creates, updates, deletes, connects, disconnects, and reorders questions, conditions, and tasks by editing the schema_version 2 draft config JSON. Condition connections create expression predicates; layout remains separate from runtime behavior.
|
||||
description: Creates, updates, deletes, connects, disconnects, reorders, and upserts self-serve answer paths by editing the schema_version 2 draft config JSON. Condition connections create expression predicates; Path Editor upserts create normal generated condition and task nodes; layout remains separate from runtime behavior.
|
||||
operationId: saveSelfserveStudioGraph
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -5613,6 +5617,29 @@ paths:
|
||||
'422':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
|
||||
/department/selfserve/studio/path-confirmations:
|
||||
post:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Confirm or reset a projected self-serve studio path
|
||||
description: Stores confirmation for a projected terminal path using its stable path and result signatures. Projections report confirmed, unconfirmed, or stale when the resulting tasks, buttons, services, or signals change.
|
||||
operationId: confirmSelfserveStudioPath
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfserveStudioPathConfirmationRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Path confirmation updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfserveStudioPathConfirmation'
|
||||
'422':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
|
||||
/department/selfserve/studio/publish:
|
||||
post:
|
||||
tags:
|
||||
@@ -6186,7 +6213,7 @@ paths:
|
||||
reference: {type: string}
|
||||
po: {type: string}
|
||||
pickup: {type: boolean}
|
||||
order_id: {type: integer}
|
||||
order_id: {type: integer, nullable: true}
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
@@ -8043,7 +8070,15 @@ paths:
|
||||
description: Worker status retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
api_commit_sha:
|
||||
type: string
|
||||
description: Running API commit SHA, or unknown when unavailable.
|
||||
|
||||
/worker/debug:
|
||||
get:
|
||||
@@ -9295,7 +9330,10 @@ paths:
|
||||
description: |
|
||||
Send a command (e.g., start, stop, reset) to a self-serve lane.
|
||||
Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here.
|
||||
Property gate command permissions are bypassed for authenticated customers with an active self-serve wash in the target department.
|
||||
Operator callers require the base command permission plus the command-specific permission. Authenticated
|
||||
customers with `list_own_department_selfserve_vehicle_conditions` may send `START` on enabled self-serve
|
||||
lanes. Customer `STOP` and property gate commands require the customer's active self-serve wash in the target
|
||||
department.
|
||||
operationId: sendSelfServeLaneCommand
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -9353,6 +9391,9 @@ paths:
|
||||
Updates the set of services that are allowed to be manually activated for a given self-serve lane,
|
||||
derived from the tasks currently shown to the user after answering the self-serve questions.
|
||||
This endpoint does not activate anything by itself; it only sets what is allowed to be activated.
|
||||
Operator callers require `modules_selfserve_lane_services_set_allowed`; authenticated customers with
|
||||
`list_own_department_selfserve_vehicle_conditions` may update their enabled self-serve lane before
|
||||
confirming a wash start.
|
||||
operationId: setSelfServeLaneAllowedServices
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -9675,7 +9716,9 @@ paths:
|
||||
description: |
|
||||
Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services
|
||||
include `MACHINE` (set via `/modules/self-serve/lane/services/allowed`). The relay is never automatically
|
||||
enabled; an explicit call to this endpoint is required.
|
||||
enabled; an explicit call to this endpoint is required. Operator callers require
|
||||
`modules_selfserve_lane_relay_enable_machine`; authenticated customers with
|
||||
`list_own_department_selfserve_vehicle_conditions` may enable it only for their active self-serve wash.
|
||||
operationId: enableSelfServeLaneMachineRelay
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -12830,6 +12873,189 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
|
||||
/superuser/releases/operations:
|
||||
get:
|
||||
tags:
|
||||
- Release Manager
|
||||
summary: List release operation runs
|
||||
operationId: listReleaseOperations
|
||||
parameters:
|
||||
- in: query
|
||||
name: channel_id
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: operation_type
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: status
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
responses:
|
||||
'200':
|
||||
description: Release operation runs
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
|
||||
/superuser/releases/operations/{id}:
|
||||
get:
|
||||
tags:
|
||||
- Release Manager
|
||||
summary: Get release operation details
|
||||
operationId: getReleaseOperation
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Release operation details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/superuser/releases/test-runs:
|
||||
post:
|
||||
tags:
|
||||
- Release Manager
|
||||
summary: Run Release Manager diagnostics
|
||||
operationId: runReleaseTest
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
responses:
|
||||
'202':
|
||||
description: Release test operation started
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
|
||||
/superuser/releases/channels/{id}/sync:
|
||||
post:
|
||||
tags:
|
||||
- Release Manager
|
||||
summary: Sync latest branch commits into a release channel
|
||||
operationId: syncReleaseChannel
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'202':
|
||||
description: Channel sync operation started
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
|
||||
/superuser/releases/issues/actions:
|
||||
post:
|
||||
tags:
|
||||
- Release Manager
|
||||
summary: Run a Release Manager issue action
|
||||
operationId: runReleaseIssueAction
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
issue_key:
|
||||
type: string
|
||||
action_id:
|
||||
type: string
|
||||
inputs:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
confirm:
|
||||
type: boolean
|
||||
additionalProperties: true
|
||||
responses:
|
||||
'200':
|
||||
description: Issue action result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
@@ -17695,10 +17921,10 @@ components:
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [create, update, delete, connect, disconnect, reorder]
|
||||
enum: [create, update, delete, connect, disconnect, reorder, upsert, upsert_path]
|
||||
entity:
|
||||
type: string
|
||||
enum: [question, condition, task]
|
||||
enum: [question, condition, task, action, path]
|
||||
description: Standalone rule operations are not accepted for schema_version 2 drafts.
|
||||
id:
|
||||
type: integer
|
||||
@@ -17958,6 +18184,45 @@ components:
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
actions:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
dynamic_image_buttons:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
decisions:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [kind, id, label, state, reason, node_ids, causes]
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
id:
|
||||
oneOf:
|
||||
- type: integer
|
||||
- type: string
|
||||
nullable: true
|
||||
label:
|
||||
type: string
|
||||
state:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
node_ids:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
causes:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
additionalProperties: true
|
||||
hardware:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
@@ -18046,6 +18311,8 @@ components:
|
||||
items: { type: integer }
|
||||
max_states: { type: integer }
|
||||
path_sample_count: { type: integer }
|
||||
confirmations:
|
||||
$ref: '#/components/schemas/SelfserveStudioPathConfirmationSummary'
|
||||
outcomes:
|
||||
type: array
|
||||
items:
|
||||
@@ -18061,6 +18328,15 @@ components:
|
||||
type: boolean
|
||||
progress:
|
||||
$ref: '#/components/schemas/SelfserveStudioPathProgress'
|
||||
confirmations:
|
||||
type: object
|
||||
properties:
|
||||
summary:
|
||||
$ref: '#/components/schemas/SelfserveStudioPathConfirmationSummary'
|
||||
removed:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SelfserveStudioPathConfirmation'
|
||||
|
||||
SelfserveStudioPathProgress:
|
||||
type: object
|
||||
@@ -18146,6 +18422,95 @@ components:
|
||||
node_ids:
|
||||
type: array
|
||||
items: { type: string }
|
||||
path_signature: { type: string }
|
||||
result_signature: { type: string }
|
||||
confirmation_status:
|
||||
type: string
|
||||
enum: [unconfirmed, confirmed, stale]
|
||||
confirmed_at:
|
||||
type: string
|
||||
nullable: true
|
||||
confirmed_by:
|
||||
type: integer
|
||||
nullable: true
|
||||
stale_reason:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
SelfserveStudioPathConfirmationSummary:
|
||||
type: object
|
||||
properties:
|
||||
confirmed: { type: integer }
|
||||
unconfirmed: { type: integer }
|
||||
stale: { type: integer }
|
||||
removed: { type: integer }
|
||||
total: { type: integer }
|
||||
|
||||
SelfserveStudioPathConfirmationRequest:
|
||||
type: object
|
||||
required: [department, path_signature]
|
||||
properties:
|
||||
department: { type: integer }
|
||||
action:
|
||||
type: string
|
||||
enum: [confirm, reset, delete, clear]
|
||||
default: confirm
|
||||
path_signature: { type: string }
|
||||
result_signature:
|
||||
type: string
|
||||
description: Required when action is confirm.
|
||||
scope:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
answers:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SelfserveStudioPathAnswer'
|
||||
result:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
SelfserveStudioPathConfirmation:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
nullable: true
|
||||
department_id: { type: integer }
|
||||
lane_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
vehicle_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
config_version_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
config_source: { type: string }
|
||||
path_signature: { type: string }
|
||||
result_signature: { type: string }
|
||||
confirmation_status:
|
||||
type: string
|
||||
enum: [unconfirmed, confirmed, stale]
|
||||
answers:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SelfserveStudioPathAnswer'
|
||||
result:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
scope:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
confirmed_at:
|
||||
type: string
|
||||
nullable: true
|
||||
confirmed_by:
|
||||
type: integer
|
||||
nullable: true
|
||||
stale_reason:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
SelfserveStudioPathTask:
|
||||
type: object
|
||||
|
||||
@@ -18,6 +18,35 @@ class departmentLanesRoute
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/department/lanes/status-toggles', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('list_department_lanes');
|
||||
self::requireParameters(['department_id']);
|
||||
$department_id = (int)self::getParameter('department_id');
|
||||
self::requireType($department_id, self::type_int());
|
||||
self::requireMinValue($department_id, 1);
|
||||
self::requireDepartmentAccess($department_id);
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('department_lanes', 'global', 1, 0, 'LIST_DEPARTMENT_LANE_STATUS_TOGGLES', 'User tried to list department lane status toggles without being logged in');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
(new logs_o())->add('department_lanes', 'global', 1, $user->id, 'LIST_DEPARTMENT_LANE_STATUS_TOGGLES', 'User listed department lane status toggles for department ' . $department_id);
|
||||
$response->success(
|
||||
array_map(
|
||||
static fn (department_lanes_o $department_lane): array => $department_lane->asArray(),
|
||||
(new department_lanes_o())->getDepartmentLanes($department_id)
|
||||
)
|
||||
);
|
||||
},
|
||||
[
|
||||
'list_department_lanes' => 'List department lane status toggles'
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/department/lanes', function () {
|
||||
|
||||
// Require the user to be logged in
|
||||
@@ -135,7 +164,7 @@ class departmentLanesRoute
|
||||
* Query parameters:
|
||||
* - department (int, required)
|
||||
* - lane (int, required)
|
||||
* - buttons (array|json|csv, optional) → ordered highlighted button IDs (0-indexed), "reset", or "start"
|
||||
* - buttons (array|json|csv, optional) → ordered highlighted button IDs (0-indexed), "reset", "start", or "program_picker"
|
||||
* - current_step (int >= 0, optional) → current click/step indicator
|
||||
* - only_current_step (bool/int, optional) → if true, only draw current step highlight
|
||||
* - vehicle_type (int|null, optional) → normalized but currently not used by machine_1
|
||||
|
||||
@@ -195,6 +195,29 @@ class departmentSelfserveStudioRoute
|
||||
'list_department_selfserve_vehicle_conditions' => 'Project grouped self-serve studio question path outcomes',
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/studio/path-confirmations', function (): void {
|
||||
global $response;
|
||||
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
|
||||
self::requireParameters(['department', 'path_signature']);
|
||||
$departmentId = (int)self::getParameter('department');
|
||||
$this->assertDepartmentAccess($user, $departmentId);
|
||||
|
||||
try {
|
||||
$payload = self::getParametersAsArray();
|
||||
$action = strtolower(trim((string)($payload['action'] ?? 'confirm')));
|
||||
$service = new selfserve_studio_graph();
|
||||
$result = in_array($action, ['delete', 'reset', 'clear'], true)
|
||||
? $service->resetPathConfirmation($departmentId, $payload)
|
||||
: $service->confirmPathOutcome($departmentId, $payload, (int)$user->id);
|
||||
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'CONFIRM_STUDIO_PATH', 'Updated self-serve studio path confirmation');
|
||||
$response->success($result);
|
||||
} catch (\RuntimeException $exception) {
|
||||
$response->error($exception->getMessage(), 422);
|
||||
}
|
||||
}, [
|
||||
'edit_department_selfserve_config_versions' => 'Confirm or reset projected self-serve studio answer paths',
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/studio/publish', function (): void {
|
||||
global $response;
|
||||
$user = $this->requireStudioUser('publish_department_selfserve_config_versions');
|
||||
|
||||
@@ -30,6 +30,8 @@ class moduleSelfServeRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
private const CUSTOMER_SELFSERVE_PERMISSION = 'list_own_department_selfserve_vehicle_conditions';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
global /** @var response $response */
|
||||
@@ -60,6 +62,60 @@ class moduleSelfServeRoute
|
||||
]
|
||||
);
|
||||
|
||||
$this->put('/modules/self-serve/lane/status', function () {
|
||||
global $response;
|
||||
|
||||
self::requirePermission('modules_selfserve_lane_status_set');
|
||||
self::requireParameters(['lane_id', 'enabled']);
|
||||
$lane_id = (int)self::getParameter('lane_id');
|
||||
self::requireType($lane_id, self::type_int());
|
||||
self::requireMinValue($lane_id, 1);
|
||||
|
||||
$department_lane = (new department_lanes_o())->select($lane_id);
|
||||
if (!$department_lane->exists()) {
|
||||
$response->error('Department lane not found', 404);
|
||||
}
|
||||
self::requireDepartmentAccess((int)$department_lane->department->value());
|
||||
|
||||
$enabled = $this->requestedBoolean('enabled');
|
||||
$target_status = $enabled ? selfserve_lane_status::AVAILABLE : selfserve_lane_status::MAINTENANCE;
|
||||
$lane = (new selfserve())->lane($lane_id);
|
||||
$lane->setLaneStatus($target_status);
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
$machine_status_audit = [
|
||||
'modified_at' => date(DATE_ATOM),
|
||||
'modified_by_user_id' => $user ? (int)$user->id : null,
|
||||
'modified_by_name' => $this->machineStatusAuditUserName($user),
|
||||
];
|
||||
$lane->setLaneStatusAudit($machine_status_audit);
|
||||
|
||||
(new logs_o())->add(
|
||||
'selfserve',
|
||||
'global',
|
||||
1,
|
||||
$user ? $user->id : 0,
|
||||
'SET_LANE_MACHINE_STATUS',
|
||||
'User set self-serve lane ' . $lane_id . ' machine status to ' . $target_status->name
|
||||
);
|
||||
|
||||
$status = (string)$lane->getLaneStatus()->name;
|
||||
$response->success([
|
||||
'id' => $lane->id,
|
||||
'status' => $status,
|
||||
'machine_status_enabled' => department_lanes_o::isOperationalStatusName($status),
|
||||
'machine_status_audit' => $machine_status_audit,
|
||||
'machine_status_modified_at' => $machine_status_audit['modified_at'],
|
||||
'machine_status_modified_by' => $machine_status_audit['modified_by_name'],
|
||||
'machine_status_modified_by_user_id' => $machine_status_audit['modified_by_user_id'],
|
||||
'lane' => $department_lane->asArray(),
|
||||
]);
|
||||
},
|
||||
[
|
||||
'modules_selfserve_lane_status_set' => 'Set self-serve lane machine status',
|
||||
]
|
||||
);
|
||||
|
||||
/** Modules > Self Serve > Lane > Wash > In-progress details */
|
||||
$this->get('/modules/self-serve/lane/wash/in-progress', function () {
|
||||
global $response;
|
||||
@@ -389,7 +445,6 @@ class moduleSelfServeRoute
|
||||
/** Modules > Self Serve > Lane > Command */
|
||||
$this->post('/modules/self-serve/lane/command', function () {
|
||||
global $response;
|
||||
self::requirePermission('modules_selfserve_lane_command_execute');
|
||||
$selfserve = new selfserve();
|
||||
// Get the request user
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -412,45 +467,75 @@ class moduleSelfServeRoute
|
||||
if ($command === null) {
|
||||
$response->error("Invalid command: " . $commandParam);
|
||||
}
|
||||
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||
$customer_number = $customer_number === null ? 0 : (int)$customer_number;
|
||||
// Require permissions for specific commands
|
||||
switch ($command) {
|
||||
case selfserve_lane_command::START:
|
||||
self::requirePermission('modules_selfserve_lane_command_execute_start');
|
||||
$this->requireSelfServeLaneCommandPermission(
|
||||
$lane,
|
||||
$customer_number,
|
||||
'modules_selfserve_lane_command_execute_start',
|
||||
false
|
||||
);
|
||||
break;
|
||||
case selfserve_lane_command::STOP:
|
||||
self::requirePermission('modules_selfserve_lane_command_execute_stop');
|
||||
$this->requireSelfServeLaneCommandPermission(
|
||||
$lane,
|
||||
$customer_number,
|
||||
'modules_selfserve_lane_command_execute_stop',
|
||||
true,
|
||||
true
|
||||
);
|
||||
break;
|
||||
case selfserve_lane_command::RESERVE:
|
||||
self::requirePermission('modules_selfserve_lane_command_execute_reserve');
|
||||
$this->requireSelfServeLaneCommandPermission(
|
||||
$lane,
|
||||
$customer_number,
|
||||
'modules_selfserve_lane_command_execute_reserve',
|
||||
false
|
||||
);
|
||||
break;
|
||||
case selfserve_lane_command::RELEASE:
|
||||
self::requirePermission('modules_selfserve_lane_command_execute_release');
|
||||
$this->requireSelfServeLaneCommandPermission(
|
||||
$lane,
|
||||
$customer_number,
|
||||
'modules_selfserve_lane_command_execute_release',
|
||||
false
|
||||
);
|
||||
break;
|
||||
case selfserve_lane_command::RESET:
|
||||
self::requirePermission('modules_selfserve_lane_command_execute_reset');
|
||||
$this->requireSelfServeLaneCommandPermission(
|
||||
$lane,
|
||||
$customer_number,
|
||||
'modules_selfserve_lane_command_execute_reset',
|
||||
false
|
||||
);
|
||||
break;
|
||||
case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE:
|
||||
$this->requirePropertyGateCommandPermission(
|
||||
'modules_selfserve_lane_command_execute_open_property_access_gate',
|
||||
$lane,
|
||||
(int)$user->customer_number->value()
|
||||
$customer_number
|
||||
);
|
||||
break;
|
||||
case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE:
|
||||
$this->requirePropertyGateCommandPermission(
|
||||
'modules_selfserve_lane_command_execute_open_property_exit_gate',
|
||||
$lane,
|
||||
(int)$user->customer_number->value()
|
||||
$customer_number
|
||||
);
|
||||
break;
|
||||
}
|
||||
// Execute the command
|
||||
try {
|
||||
$this->applyShellyTransportOverride($lane);
|
||||
$subuser = (new authentication())->get_subuser();
|
||||
$args = new \modules\selfserve\classes\selfserve_lane_command_arguments();
|
||||
$args->setParameters([
|
||||
...$this->getParametersAsArray(), // Pass all parameters
|
||||
'customer_number' => (int)$user->customer_number->value(), // Get customer number from request user
|
||||
'customer_number' => $customer_number, // Get customer number from request user
|
||||
'subuser_id' => $subuser === false ? null : (int)$subuser->id,
|
||||
]);
|
||||
$lane->execute($command, $args);
|
||||
$response->success([
|
||||
@@ -493,7 +578,6 @@ class moduleSelfServeRoute
|
||||
/** Modules > Self Serve > Lane > Allowed services (derived from shown tasks) */
|
||||
$this->post('/modules/self-serve/lane/services/allowed', function () {
|
||||
global $response;
|
||||
self::requirePermission('modules_selfserve_lane_services_set_allowed');
|
||||
$selfserve = new selfserve();
|
||||
// Validate parameters
|
||||
self::requireParameters(['lane_id']);
|
||||
@@ -516,6 +600,8 @@ class moduleSelfServeRoute
|
||||
$task_ids = array_values(array_unique(array_map(fn($v) => (int)$v, $task_ids_param)));
|
||||
// Build allowed services from provided tasks
|
||||
$lane = $selfserve->lane($lane_id);
|
||||
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||
self::requirePermission('modules_selfserve_lane_services_set_allowed');
|
||||
$allowed_services = [];
|
||||
foreach ($task_ids as $tid) {
|
||||
if ($tid <= 0) continue;
|
||||
@@ -835,7 +921,6 @@ class moduleSelfServeRoute
|
||||
/** Modules > Self Serve > Lane > Relay > Enable MACHINE (manual, gated by allowed services) */
|
||||
$this->post('/modules/self-serve/lane/relay/machine/enable', function () {
|
||||
global $response;
|
||||
self::requirePermission('modules_selfserve_lane_relay_enable_machine');
|
||||
$selfserve = new selfserve();
|
||||
// Validate parameters
|
||||
self::requireParameters(['lane_id']);
|
||||
@@ -848,6 +933,8 @@ class moduleSelfServeRoute
|
||||
self::requireMinValue($duration, 1);
|
||||
}
|
||||
$lane = $selfserve->lane($lane_id);
|
||||
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||
self::requirePermission('modules_selfserve_lane_relay_enable_machine');
|
||||
try {
|
||||
$this->applyShellyTransportOverride($lane);
|
||||
$lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration);
|
||||
@@ -1271,9 +1358,87 @@ class moduleSelfServeRoute
|
||||
$lane->setShellyTransportOverride($transport);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $permissions
|
||||
*/
|
||||
private function hasAllPermissions(array $permissions): bool
|
||||
{
|
||||
foreach ($permissions as $permission) {
|
||||
if (!$this->hasPermission($permission)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $elevated_permissions
|
||||
*/
|
||||
private function requireSelfServeLaneAccess(
|
||||
selfserve_lane $lane,
|
||||
int $customer_number,
|
||||
array $elevated_permissions,
|
||||
bool $requires_active_wash = false,
|
||||
bool $requires_operational_lane = true
|
||||
): void {
|
||||
if ($this->hasAllPermissions($elevated_permissions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($requires_active_wash) {
|
||||
$customer_allowed = $this->canCustomerUseActiveSelfServeLane($lane, $customer_number)
|
||||
&& (!$requires_operational_lane || $this->isLaneSelfServeOperationallyEnabled($lane));
|
||||
} else {
|
||||
$customer_allowed = $this->canCustomerUseSelfServeLane($lane, $customer_number);
|
||||
}
|
||||
|
||||
if ($customer_allowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]);
|
||||
}
|
||||
|
||||
private function requireSelfServeLaneCommandPermission(
|
||||
selfserve_lane $lane,
|
||||
int $customer_number,
|
||||
string $command_permission,
|
||||
bool $allow_customer_self_serve,
|
||||
bool $requires_active_wash = false
|
||||
): void {
|
||||
$elevated_permissions = [
|
||||
'modules_selfserve_lane_command_execute',
|
||||
$command_permission,
|
||||
];
|
||||
if ($this->hasAllPermissions($elevated_permissions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($allow_customer_self_serve) {
|
||||
$customer_allowed = $requires_active_wash
|
||||
? $this->canCustomerUseActiveSelfServeLane($lane, $customer_number)
|
||||
: $this->canCustomerUseSelfServeLane($lane, $customer_number);
|
||||
|
||||
if ($customer_allowed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->emitForbidden(
|
||||
$allow_customer_self_serve
|
||||
? [...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]
|
||||
: $elevated_permissions
|
||||
);
|
||||
}
|
||||
|
||||
private function requirePropertyGateCommandPermission(string $permission, selfserve_lane $lane, int $customer_number): void
|
||||
{
|
||||
if (self::hasPermission($permission)) {
|
||||
$elevated_permissions = [
|
||||
'modules_selfserve_lane_command_execute',
|
||||
$permission,
|
||||
];
|
||||
if ($this->hasAllPermissions($elevated_permissions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1281,21 +1446,70 @@ class moduleSelfServeRoute
|
||||
return;
|
||||
}
|
||||
|
||||
self::requirePermission($permission);
|
||||
$this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]);
|
||||
}
|
||||
|
||||
protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool
|
||||
{
|
||||
return $customer_number > 0
|
||||
&& $this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)
|
||||
&& $this->isLaneSelfServeOperationallyEnabled($lane);
|
||||
}
|
||||
|
||||
protected function canCustomerUseActiveSelfServeLane(selfserve_lane $lane, int $customer_number): bool
|
||||
{
|
||||
if ($customer_number <= 0 || !$this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if ((int)$lane->getCustomerNumber() === $customer_number) {
|
||||
return true;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fall back to the persisted session lookup below.
|
||||
}
|
||||
|
||||
$department_id = $this->departmentIdForLane($lane);
|
||||
return $department_id > 0
|
||||
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
|
||||
}
|
||||
|
||||
protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
|
||||
{
|
||||
if ($customer_number <= 0) {
|
||||
return $this->canCustomerUseActiveSelfServeLane($lane, $customer_number);
|
||||
}
|
||||
|
||||
protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool
|
||||
{
|
||||
try {
|
||||
if (empty($lane->department_lane) || !$lane->department_lane->isSelfServeEnabled()) {
|
||||
return false;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$department_id = (int)$lane->department_lane?->department?->value();
|
||||
$department_id = $this->departmentIdForLane($lane);
|
||||
if ($department_id <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
|
||||
try {
|
||||
$department = (new departments_o())->select($department_id);
|
||||
return $department->exists() && $department->getSelfServeEnabled();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function departmentIdForLane(selfserve_lane $lane): int
|
||||
{
|
||||
try {
|
||||
return (int)$lane->department_lane?->department?->value();
|
||||
} catch (\Throwable) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool
|
||||
@@ -1389,6 +1603,33 @@ class moduleSelfServeRoute
|
||||
return $toggle_after;
|
||||
}
|
||||
|
||||
private function machineStatusAuditUserName(?object $user): ?string
|
||||
{
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['display_name', 'email'] as $property) {
|
||||
if (!isset($user->{$property}) || !is_object($user->{$property}) || !method_exists($user->{$property}, 'value')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = trim((string)$user->{$property}->value());
|
||||
if ($value !== '' && strtolower($value) !== 'unnamed') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($user->customer_number) && is_object($user->customer_number) && method_exists($user->customer_number, 'value')) {
|
||||
$customer_number = (int)$user->customer_number->value();
|
||||
if ($customer_number > 0) {
|
||||
return 'Kunde ' . $customer_number;
|
||||
}
|
||||
}
|
||||
|
||||
return isset($user->id) ? 'Bruger #' . (int)$user->id : null;
|
||||
}
|
||||
|
||||
private function requestedBoolean(string $parameter, bool $default = false): bool
|
||||
{
|
||||
if (!self::isParametersSet([$parameter])) {
|
||||
|
||||
@@ -4,28 +4,20 @@ namespace routes;
|
||||
|
||||
use traits\route_t;
|
||||
|
||||
require_once dirname(__DIR__) . '/classes/cors_policy.php';
|
||||
|
||||
class optionsRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// When the OPTIONS method is requested, accept all using regex
|
||||
$this->options('/.*', function () {
|
||||
global $CORS;
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$allowed_origins = array_map('trim', explode(',', (string)($CORS ?? '*')));
|
||||
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
||||
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
||||
header("Access-Control-Allow-Credentials: true");
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *');
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(200);
|
||||
} else {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'message' => 'CORS origin not allowed']);
|
||||
}
|
||||
$preflight = \classes\cors_policy::preflightResponse($_SERVER['HTTP_ORIGIN'] ?? '', (string)($CORS ?? ''));
|
||||
\classes\cors_policy::emitHeaders($preflight['headers']);
|
||||
http_response_code($preflight['status']);
|
||||
echo $preflight['body'];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use objects\departments_o;
|
||||
use objects\logs_o;
|
||||
use objects\order_bookings_o;
|
||||
use objects\order_items_o;
|
||||
use objects\orders_o;
|
||||
use objects\products_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
@@ -265,6 +266,7 @@ class orderBookingRoute
|
||||
$po = self::getTargetPo(false); // String | Null
|
||||
$pickup = self::getTargetPickup(false); // Bool | Null
|
||||
$items = self::getTargetItems(false); // Array of order_items_o objects
|
||||
$order_id_was_set = self::isParametersSet(['order_id']);
|
||||
$order_id = self::getTargetOrderId(false); // Int | Null
|
||||
/** Authentication */
|
||||
$auth = new authentication();
|
||||
@@ -293,6 +295,7 @@ class orderBookingRoute
|
||||
/**
|
||||
* Update the object
|
||||
*/
|
||||
$previous_order_id = (int)($object->order_id->value() ?? 0);
|
||||
$data = [
|
||||
...(self::hasPermission($permission_other) && !empty($customer_number) ? [
|
||||
'customer_number' => (int)$customer_number->customer_number->value(),
|
||||
@@ -307,9 +310,12 @@ class orderBookingRoute
|
||||
...(isset($po) ? ['po' => $po] : []),
|
||||
...(isset($pickup) ? ['pickup' => $pickup] : []),
|
||||
...(isset($items) ? ['items' => $items] : []),
|
||||
...(isset($order_id) ? ['order_id' => $order_id] : []),
|
||||
...($order_id_was_set ? ['order_id' => $order_id] : []),
|
||||
];
|
||||
$object->update($data);
|
||||
if ($order_id_was_set && $order_id === null) {
|
||||
self::clearMatchingOrderBookingLink($object, $previous_order_id);
|
||||
}
|
||||
/**
|
||||
* Return the object
|
||||
*/
|
||||
@@ -529,7 +535,7 @@ class orderBookingRoute
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function getTargetOrderId(bool $required = true): int|string|null
|
||||
private function getTargetOrderId(bool $required = true): int|null
|
||||
{
|
||||
global $response;
|
||||
$parameter = 'order_id';
|
||||
@@ -541,7 +547,7 @@ class orderBookingRoute
|
||||
} else {
|
||||
self::requireTypeIn(self::getParameter($parameter), [self::type_int(), self::type_null()]);
|
||||
// Check if the value is null
|
||||
if ($this->getParameter($parameter) === null) return "null";
|
||||
if ($this->getParameter($parameter) === null) return null;
|
||||
}
|
||||
self::requireMinLength($parameter, 1);
|
||||
self::requireMaxLength($parameter, 9);
|
||||
@@ -551,6 +557,28 @@ class orderBookingRoute
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function clearMatchingOrderBookingLink(order_bookings_o $booking, int $previous_order_id): void
|
||||
{
|
||||
if ($previous_order_id <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$previous_order = (new orders_o())->select($previous_order_id);
|
||||
if (!$previous_order->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int)($previous_order->booking_id->value() ?? 0) !== (int)$booking->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$previous_order->booking_id->set(null);
|
||||
$previous_order->objectChanged();
|
||||
}
|
||||
|
||||
private function getTargetCustomer(bool $required = true): users_o|null
|
||||
{
|
||||
global $response;
|
||||
|
||||
@@ -6,6 +6,7 @@ use classes\authentication;
|
||||
use objects\logs_o;
|
||||
use objects\order_items_o;
|
||||
use objects\orders_o;
|
||||
use objects\products_o;
|
||||
use traits\route_t;
|
||||
|
||||
class orderItemsRoute
|
||||
@@ -69,6 +70,13 @@ class orderItemsRoute
|
||||
$price = (int)self::getParameter('price');
|
||||
}
|
||||
}
|
||||
$product = (new products_o())->getProductById((int)$data['product_id']);
|
||||
if (!$product->exists()) {
|
||||
$response->error('Product not found', 404);
|
||||
}
|
||||
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
|
||||
$response->error('Notes is required for this product', 400);
|
||||
}
|
||||
|
||||
// Add the order item to the order This is done individually, to make the notes to the individual order items possible
|
||||
$order_items = (new order_items_o());
|
||||
@@ -203,6 +211,14 @@ class orderItemsRoute
|
||||
if (!isset($data['quantity'])) {
|
||||
$response->error('Quantity is required', 400);
|
||||
}
|
||||
$orderItem = (new order_items_o())->getOrderItemById((int)$data['id']);
|
||||
if (!$orderItem->exists()) {
|
||||
$response->error('Order item not found', 404);
|
||||
}
|
||||
$product = (new products_o())->getProductById((int)$orderItem->product_id->value());
|
||||
if ($product->requiresOrderItemNote() && trim((string)$data['notes']) === '') {
|
||||
$response->error('Notes is required for this product', 400);
|
||||
}
|
||||
// Update the order item
|
||||
(new order_items_o())->updateOrderItem((int)$data['id'], (int)$data['price'], (string)$data['notes'], (string)$data['reference'], (int)$data['quantity']);
|
||||
// Log the incident
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\release_manager;
|
||||
use traits\route_t;
|
||||
|
||||
class pingRoute
|
||||
@@ -15,6 +16,8 @@ class pingRoute
|
||||
$response->success([
|
||||
'message' => 'pong',
|
||||
'time' => date('c'),
|
||||
'backend_version' => release_manager::backendVersion(),
|
||||
'api_commit_sha' => release_manager::backendCommitSha(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ class productsRoute
|
||||
'piktogram' => (string)$product['piktogram'],
|
||||
'economic_product_id' => (int)$product['economic_product_id'],
|
||||
'apply_category_discount' => (boolean)$product['apply_category_discount'],
|
||||
'requires_note' => (boolean)$product['requires_note'],
|
||||
'requires_note' => \objects\products_o::productDataRequiresOrderItemNote($product),
|
||||
'created_at' => (string)$product['created_at'],
|
||||
'updated_at' => (string)$product['updated_at'],
|
||||
'addons' => (new product_options_o())->getProductOptions($product['id']),
|
||||
|
||||
@@ -45,6 +45,21 @@ class releaseManagerRoute
|
||||
}
|
||||
});
|
||||
|
||||
$this->post('/release/gate/test-runs', function () {
|
||||
global $response;
|
||||
$manager = new release_manager();
|
||||
if (!$manager->verifyReleaseGateToken($this->releaseGateToken())) {
|
||||
$response->error(['message' => 'Invalid release gate token.'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$response->success($manager->runReleaseTest($this->requestPayload(), null), 202);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
});
|
||||
|
||||
$this->get('/superuser/releases', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_release_manager_view');
|
||||
@@ -61,6 +76,34 @@ class releaseManagerRoute
|
||||
'superuser_release_manager_view' => 'View Release Manager source configuration',
|
||||
]);
|
||||
|
||||
$this->get('/superuser/releases/operations', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_release_manager_view');
|
||||
$response->success((new release_manager())->listOperations($this->getParametersAsArray()));
|
||||
}, [
|
||||
'superuser_release_manager_view' => 'View release operation runs',
|
||||
]);
|
||||
|
||||
$this->get('/superuser/releases/operations/{id}', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_release_manager_view');
|
||||
try {
|
||||
$response->success((new release_manager())->operationDetail($this->routeId()));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 404);
|
||||
}
|
||||
}, [
|
||||
'superuser_release_manager_view' => 'Inspect release operation diagnostics',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/releases/test-runs', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_release_manager_deploy');
|
||||
$response->success((new release_manager())->runReleaseTest($this->requestPayload(), $this->actorUserId()), 202);
|
||||
}, [
|
||||
'superuser_release_manager_deploy' => 'Run Release Manager tests with operation diagnostics',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/releases/config', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_release_manager_manage');
|
||||
@@ -161,6 +204,18 @@ class releaseManagerRoute
|
||||
'superuser_release_manager_rollback' => 'Rollback an active release channel',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/releases/channels/{id}/sync', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_release_manager_deploy');
|
||||
try {
|
||||
$response->success((new release_manager())->syncChannel($this->routeId(), $this->actorUserId()), 202);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'superuser_release_manager_deploy' => 'Sync the latest frontend and API branch commits into a release channel',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/releases/channels/{id}/bundle', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_release_manager_deploy');
|
||||
@@ -380,6 +435,14 @@ class releaseManagerRoute
|
||||
'superuser_release_manager_deploy' => 'Promote a deployment to its release channel',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/releases/issues/actions', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_release_manager_deploy');
|
||||
$response->success((new release_manager())->runIssueAction($this->requestPayload(), $this->actorUserId()));
|
||||
}, [
|
||||
'superuser_release_manager_deploy' => 'Run a safe Release Manager issue resolution action',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/releases/replay-targets', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_release_manager_replay');
|
||||
@@ -451,4 +514,20 @@ class releaseManagerRoute
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function releaseGateToken(): string
|
||||
{
|
||||
$headers = function_exists('getallheaders') ? getallheaders() : [];
|
||||
$authorization = (string)($headers['Authorization'] ?? $headers['authorization'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? '');
|
||||
if (preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches) === 1) {
|
||||
return trim($matches[1]);
|
||||
}
|
||||
|
||||
return trim((string)(
|
||||
$headers['X-Release-Gate-Token']
|
||||
?? $headers['x-release-gate-token']
|
||||
?? $_SERVER['HTTP_X_RELEASE_GATE_TOKEN']
|
||||
?? ''
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,14 @@ class subusersRoute
|
||||
}
|
||||
}
|
||||
|
||||
private function requireSubuserPasswordPolicy(string $password): void
|
||||
{
|
||||
self::requireType($password, self::type_string());
|
||||
self::requireMinLength('password', subusers_o::PASSWORD_MIN_LENGTH);
|
||||
self::requireMaxLength('password', subusers_o::PASSWORD_MAX_LENGTH);
|
||||
self::requireRegex($password, subusers_o::PASSWORD_PATTERN, subusers_o::PASSWORD_COMPLEXITY_MESSAGE);
|
||||
}
|
||||
|
||||
private function requireManagedCustomerScope(subusers_permission_node_key $node, ?int $targetCustomerNumber = null): int
|
||||
{
|
||||
global $response;
|
||||
@@ -121,6 +129,36 @@ class subusersRoute
|
||||
return $normalized === '' ? null : $normalized;
|
||||
}
|
||||
|
||||
private function resolveCustomerNames(array $customerNumbers): array
|
||||
{
|
||||
$customerNumbers = array_values(array_unique(array_filter(
|
||||
array_map('intval', $customerNumbers),
|
||||
static fn (int $customerNumber): bool => $customerNumber > 0
|
||||
)));
|
||||
|
||||
if ($customerNumbers === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (new users_o())->getCustomerNames($customerNumbers, false);
|
||||
}
|
||||
|
||||
private function resolveCustomerName(int $customerNumber, array $customerNames = []): ?string
|
||||
{
|
||||
if ($customerNumber <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = (string)$customerNumber;
|
||||
$name = $customerNames[$key] ?? null;
|
||||
if (!is_string($name)) {
|
||||
$name = $this->resolveCustomerNames([$customerNumber])[$key] ?? null;
|
||||
}
|
||||
|
||||
$name = trim((string)$name);
|
||||
return $name === '' || $name === 'Unknown Customer' ? null : $name;
|
||||
}
|
||||
|
||||
private function assertSubuserIdentifiersAvailable(
|
||||
?int $phoneCountryCode,
|
||||
?int $phone,
|
||||
@@ -244,10 +282,10 @@ class subusersRoute
|
||||
];
|
||||
}
|
||||
|
||||
private function buildSubuserManagementPayload(subusers_o $subuser, int $customerNumber): array
|
||||
private function buildSubuserManagementPayload(subusers_o $subuser, int $customerNumber, ?string $customerName = null): array
|
||||
{
|
||||
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
|
||||
$grantPermissions = $grant ? $this->parsePermissionsPayload($grant->permissions->value(), []) : [];
|
||||
$grantPermissions = $grant ? subuser_grants_o::normalizePermissionsValue($grant->permissions->value()) : [];
|
||||
$setupRequired = $subuser->requiresSetup();
|
||||
$grantEnabled = $grant ? (bool)$grant->enabled->value() : false;
|
||||
$inviteAccepted = !$setupRequired;
|
||||
@@ -274,6 +312,8 @@ class subusersRoute
|
||||
'invite_accepted' => $inviteAccepted,
|
||||
'can_resend_invite' => $setupRequired,
|
||||
'profile_editable_by_manager' => false,
|
||||
'customer_number' => $customerNumber,
|
||||
'customer_name' => $customerName ?? $this->resolveCustomerName($customerNumber),
|
||||
'grant_id' => $grant ? (int)$grant->id : null,
|
||||
'grant_enabled' => $grantEnabled,
|
||||
'grant_note' => $grant ? $grant->note->value() : null,
|
||||
@@ -290,6 +330,10 @@ class subusersRoute
|
||||
'enabled' => 1,
|
||||
'deleted_at' => null,
|
||||
], ['permissions', 'billing_customer_number']);
|
||||
$customerNames = $this->resolveCustomerNames(array_map(
|
||||
static fn (array $grant): int => (int)($grant['billing_customer_number'] ?? 0),
|
||||
$grants
|
||||
));
|
||||
|
||||
return [
|
||||
'id' => (int)$subuser->id,
|
||||
@@ -298,11 +342,12 @@ class subusersRoute
|
||||
'email' => $subuser->email->value(),
|
||||
'phone_country_code' => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null,
|
||||
'phone' => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null,
|
||||
'grants' => array_map(function ($grant) {
|
||||
'grants' => array_map(function ($grant) use ($customerNames) {
|
||||
$customerNumber = (int)$grant['billing_customer_number'];
|
||||
return [
|
||||
'name' => (new users_o())->getCustomerName((int)$grant['billing_customer_number']),
|
||||
'billing_customer_number' => (int)$grant['billing_customer_number'],
|
||||
'permissions' => $this->parsePermissionsPayload($grant['permissions'] ?? null, []),
|
||||
'name' => $this->resolveCustomerName($customerNumber, $customerNames),
|
||||
'billing_customer_number' => $customerNumber,
|
||||
'permissions' => subuser_grants_o::normalizePermissionsValue($grant['permissions'] ?? null),
|
||||
];
|
||||
}, $grants),
|
||||
'created_at' => $subuser->created_at->value() ?? null,
|
||||
@@ -312,6 +357,297 @@ class subusersRoute
|
||||
];
|
||||
}
|
||||
|
||||
private function parseSuperuserPaginationRequest(): array
|
||||
{
|
||||
global $response;
|
||||
|
||||
$page = max(1, (int)($response->getRequestParameter('page') ?: 1));
|
||||
$limitRaw = $response->getRequestParameter('limit');
|
||||
$limit = is_string($limitRaw) && strtolower($limitRaw) === 'all'
|
||||
? 1000
|
||||
: (int)($limitRaw ?: 100);
|
||||
$limit = max(1, min($limit, 1000));
|
||||
|
||||
$search = $this->normalizeOptionalString($response->getRequestParameter('search'));
|
||||
$orderRaw = (string)($response->getRequestParameter('order') ?: 'created_at:DESC');
|
||||
$orderParts = explode(':', $orderRaw, 2);
|
||||
$orderField = $orderParts[0] ?? 'created_at';
|
||||
$orderDirection = strtoupper($orderParts[1] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
||||
$allowedOrderFields = [
|
||||
'id' => 's.`id`',
|
||||
'created_at' => 's.`created_at`',
|
||||
'updated_at' => 'row_updated_at',
|
||||
'customer_number' => 'g.`billing_customer_number`',
|
||||
'grant_id' => 'g.`id`',
|
||||
'name' => 's.`name`',
|
||||
];
|
||||
|
||||
if (!isset($allowedOrderFields[$orderField])) {
|
||||
$orderField = 'created_at';
|
||||
}
|
||||
|
||||
return [
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'search' => $search,
|
||||
'order_field' => $orderField,
|
||||
'order_sql' => $allowedOrderFields[$orderField],
|
||||
'order_direction' => $orderDirection,
|
||||
];
|
||||
}
|
||||
|
||||
private function bindStatementParameters(\mysqli_stmt $statement, string $types, array $params): void
|
||||
{
|
||||
if ($params === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$refs = [];
|
||||
foreach ($params as $key => $value) {
|
||||
$refs[$key] = &$params[$key];
|
||||
}
|
||||
|
||||
$statement->bind_param($types, ...$refs);
|
||||
}
|
||||
|
||||
private function buildSuperuserSubuserManagementPayload(array $row): array
|
||||
{
|
||||
$grantPermissions = subuser_grants_o::normalizePermissionsValue($row['grant_permissions'] ?? null);
|
||||
$setupRequired = !is_string($row['password'] ?? null) || trim((string)$row['password']) === '';
|
||||
$grantEnabled = (bool)((int)($row['grant_enabled'] ?? 0));
|
||||
|
||||
$accessState = 'inactive';
|
||||
if (!empty($row['grant_id']) && $grantEnabled) {
|
||||
$accessState = $setupRequired ? 'pending_setup' : 'active';
|
||||
} elseif (!empty($row['grant_id'])) {
|
||||
$accessState = 'disabled';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)$row['id'],
|
||||
'username' => $row['username'] ?? null,
|
||||
'name' => $row['name'] ?? null,
|
||||
'email' => $row['email'] ?? null,
|
||||
'phone_country_code' => $row['phone_country_code'] !== null ? (int)$row['phone_country_code'] : null,
|
||||
'phone' => $row['phone'] !== null ? (int)$row['phone'] : null,
|
||||
'created_at' => $row['created_at'] ?? null,
|
||||
'updated_at' => $row['row_updated_at'] ?? $row['updated_at'] ?? null,
|
||||
'suspended_at' => $row['suspended_at'] ?? null,
|
||||
'two_factor_enabled' => (bool)((int)($row['two_factor_enabled'] ?? 0)),
|
||||
'setup_required' => $setupRequired,
|
||||
'invite_accepted' => !$setupRequired,
|
||||
'can_resend_invite' => $setupRequired,
|
||||
'profile_editable_by_manager' => false,
|
||||
'customer_number' => (int)$row['customer_number'],
|
||||
'customer_name' => $row['customer_name'] ?: null,
|
||||
'grant_id' => (int)$row['grant_id'],
|
||||
'grant_enabled' => $grantEnabled,
|
||||
'grant_note' => $row['grant_note'] ?? null,
|
||||
'grant_permissions' => $grantPermissions,
|
||||
'permissions' => $grantPermissions,
|
||||
'grant_created_at' => $row['grant_created_at'] ?? null,
|
||||
'grant_updated_at' => $row['grant_updated_at'] ?? null,
|
||||
'access_state' => $accessState,
|
||||
];
|
||||
}
|
||||
|
||||
private function listSuperuserSubusers(): array
|
||||
{
|
||||
global $db, $response;
|
||||
|
||||
$pagination = $this->parseSuperuserPaginationRequest();
|
||||
$offset = ((int)$pagination['page'] - 1) * (int)$pagination['limit'];
|
||||
$where = ['g.`deleted_at` IS NULL'];
|
||||
$params = [];
|
||||
$types = '';
|
||||
|
||||
$includeNonEnabled = true;
|
||||
if (self::isParametersSet(['include_non_enabled'])) {
|
||||
$tmp = filter_var(self::getParameter('include_non_enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
$includeNonEnabled = $tmp === null ? true : (bool)$tmp;
|
||||
}
|
||||
if (!$includeNonEnabled) {
|
||||
$where[] = 'g.`enabled` = 1';
|
||||
}
|
||||
|
||||
if ($pagination['search'] !== null) {
|
||||
$where[] = "(
|
||||
CAST(s.`id` AS CHAR) LIKE ?
|
||||
OR s.`username` LIKE ?
|
||||
OR s.`name` LIKE ?
|
||||
OR s.`email` LIKE ?
|
||||
OR CAST(s.`phone_country_code` AS CHAR) LIKE ?
|
||||
OR CAST(s.`phone` AS CHAR) LIKE ?
|
||||
OR CAST(g.`billing_customer_number` AS CHAR) LIKE ?
|
||||
OR g.`note` LIKE ?
|
||||
OR u.`display_name` LIKE ?
|
||||
)";
|
||||
$search = '%' . $pagination['search'] . '%';
|
||||
for ($i = 0; $i < 9; $i++) {
|
||||
$params[] = $search;
|
||||
$types .= 's';
|
||||
}
|
||||
}
|
||||
|
||||
$whereSql = 'WHERE ' . implode(' AND ', $where);
|
||||
$fromSql = "
|
||||
FROM `subuser_grants` g
|
||||
INNER JOIN `subusers` s ON s.`id` = g.`subuser`
|
||||
LEFT JOIN `users` u ON u.`customer_number` = g.`billing_customer_number`
|
||||
";
|
||||
|
||||
$countSql = "SELECT COUNT(*) AS `count` $fromSql $whereSql";
|
||||
$countStatement = $db->conn->prepare($countSql);
|
||||
if ($countStatement === false) {
|
||||
throw new Exception('Failed to prepare subuser count query: ' . $db->conn->error);
|
||||
}
|
||||
$this->bindStatementParameters($countStatement, $types, $params);
|
||||
$countStatement->execute();
|
||||
$countResult = $countStatement->get_result();
|
||||
$total = (int)($countResult->fetch_assoc()['count'] ?? 0);
|
||||
$countStatement->close();
|
||||
|
||||
$dataSql = "
|
||||
SELECT
|
||||
s.`id`,
|
||||
s.`username`,
|
||||
s.`password`,
|
||||
s.`name`,
|
||||
s.`email`,
|
||||
s.`phone_country_code`,
|
||||
s.`phone`,
|
||||
s.`two_factor_enabled`,
|
||||
s.`created_at`,
|
||||
s.`updated_at`,
|
||||
s.`suspended_at`,
|
||||
g.`id` AS `grant_id`,
|
||||
g.`billing_customer_number` AS `customer_number`,
|
||||
g.`enabled` AS `grant_enabled`,
|
||||
g.`note` AS `grant_note`,
|
||||
g.`permissions` AS `grant_permissions`,
|
||||
g.`created_at` AS `grant_created_at`,
|
||||
g.`updated_at` AS `grant_updated_at`,
|
||||
COALESCE(g.`updated_at`, s.`updated_at`) AS `row_updated_at`,
|
||||
u.`display_name` AS `customer_name`
|
||||
$fromSql
|
||||
$whereSql
|
||||
ORDER BY {$pagination['order_sql']} {$pagination['order_direction']}
|
||||
LIMIT ? OFFSET ?
|
||||
";
|
||||
|
||||
$dataStatement = $db->conn->prepare($dataSql);
|
||||
if ($dataStatement === false) {
|
||||
throw new Exception('Failed to prepare subuser list query: ' . $db->conn->error);
|
||||
}
|
||||
$dataParams = [...$params, (int)$pagination['limit'], $offset];
|
||||
$this->bindStatementParameters($dataStatement, $types . 'ii', $dataParams);
|
||||
$dataStatement->execute();
|
||||
$result = $dataStatement->get_result();
|
||||
$rows = $result->fetch_all(MYSQLI_ASSOC);
|
||||
$dataStatement->close();
|
||||
|
||||
$response->paginate(
|
||||
(int)$pagination['page'],
|
||||
(int)$pagination['limit'],
|
||||
$total,
|
||||
$pagination['search'],
|
||||
null,
|
||||
[$pagination['order_field'] => $pagination['order_direction']]
|
||||
);
|
||||
|
||||
return array_map(fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row), $rows);
|
||||
}
|
||||
|
||||
private function handleInviteSubuserForCustomer(int $customerNumber): void
|
||||
{
|
||||
global $response;
|
||||
|
||||
if ($customerNumber <= 0) {
|
||||
$response->error('Customer number is required', 400);
|
||||
}
|
||||
|
||||
self::requireParameters(['name', 'phone_country_code', 'phone']);
|
||||
|
||||
$name = $this->normalizeOptionalString(self::getParameter('name'));
|
||||
$phoneCountryCode = (int)self::getParameter('phone_country_code');
|
||||
$phone = (int)self::getParameter('phone');
|
||||
$note = self::isParametersSet(['note']) ? $this->normalizeOptionalString(self::getParameter('note')) : null;
|
||||
$enabled = true;
|
||||
if (self::isParametersSet(['enabled'])) {
|
||||
$tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
$enabled = $tmp === null ? true : (bool)$tmp;
|
||||
}
|
||||
$permissions = self::isParametersSet(['permissions'])
|
||||
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
|
||||
: null;
|
||||
|
||||
if ($name === null || strlen($name) < 3 || strlen($name) > 255) {
|
||||
$response->error('Name must be between 3 and 255 characters long', 400);
|
||||
}
|
||||
self::requireType($phoneCountryCode, self::type_int());
|
||||
self::requireType($phone, self::type_int());
|
||||
self::requireMinLength('phone_country_code', 1);
|
||||
self::requireMaxLength('phone_country_code', 3);
|
||||
self::requireMinLength('phone', 4);
|
||||
self::requireMaxLength('phone', 15);
|
||||
if ($note !== null && strlen($note) > 65535) {
|
||||
$response->error('Note must be at most 65535 characters long', 400);
|
||||
}
|
||||
|
||||
$subuser = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone);
|
||||
if ($subuser === null) {
|
||||
try {
|
||||
$subuser = (new subusers_o())->add(
|
||||
null,
|
||||
null,
|
||||
$name,
|
||||
null,
|
||||
$phoneCountryCode,
|
||||
$phone
|
||||
);
|
||||
} catch (Exception $exception) {
|
||||
$response->error($exception->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
|
||||
if ($grant === null) {
|
||||
try {
|
||||
$grant = (new subuser_grants_o())->add(
|
||||
$customerNumber,
|
||||
(int)$subuser->id,
|
||||
$enabled,
|
||||
$note,
|
||||
$permissions ?? subuser_grants_o::defaultPermissions
|
||||
);
|
||||
} catch (Exception $exception) {
|
||||
$response->error('Failed to create subuser grant', 500);
|
||||
}
|
||||
} else {
|
||||
$grantUpdates = ['enabled' => $enabled];
|
||||
if (self::isParametersSet(['note'])) {
|
||||
$grantUpdates['note'] = $note;
|
||||
}
|
||||
if ($permissions !== null) {
|
||||
$grantUpdates['permissions'] = $permissions;
|
||||
}
|
||||
try {
|
||||
$grant->update($grantUpdates);
|
||||
} catch (Exception $exception) {
|
||||
$response->error('Failed to update subuser grant', 500);
|
||||
}
|
||||
$grant = (new subuser_grants_o())->select((int)$grant->id);
|
||||
$grant->getObjectProperties();
|
||||
}
|
||||
|
||||
$invite = $this->issueSetupInvite($subuser);
|
||||
$response->success([
|
||||
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
|
||||
'grant' => $grant->asArray(),
|
||||
'invite' => $invite,
|
||||
]);
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// =============================
|
||||
@@ -387,7 +723,7 @@ class subusersRoute
|
||||
'name' => $subuser->name->value(),
|
||||
'enabled' => $o->enabled,
|
||||
'note' => $o->note,
|
||||
'permissions' => json_decode($o->permissions, true) ?: [],
|
||||
'permissions' => subuser_grants_o::normalizePermissionsValue($o->permissions ?? null),
|
||||
'created_at' => $o->created_at,
|
||||
'updated_at' => $o->updated_at,
|
||||
];
|
||||
@@ -649,10 +985,7 @@ class subusersRoute
|
||||
$token = (string)self::getParameter('token');
|
||||
$password = (string)self::getParameter('password');
|
||||
$name = (string)self::getParameter('name');
|
||||
self::requireType($password, self::type_string());
|
||||
self::requireMinLength('password', 8);
|
||||
self::requireMaxLength('password', 255);
|
||||
self::requireRegex($password, '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/', 'Password must contain at least one uppercase letter, one lowercase letter, and one number');
|
||||
$this->requireSubuserPasswordPolicy($password);
|
||||
self::requireType($name, self::type_string());
|
||||
self::requireMinLength('name', 3);
|
||||
self::requireMaxLength('name', 255);
|
||||
@@ -747,9 +1080,7 @@ class subusersRoute
|
||||
}
|
||||
self::requireParameters(['password']);
|
||||
$password = (string)self::getParameter('password');
|
||||
self::requireType($password, self::type_string());
|
||||
self::requireMinLength('password', 8);
|
||||
self::requireMaxLength('password', 255);
|
||||
$this->requireSubuserPasswordPolicy($password);
|
||||
try {
|
||||
if (password_verify($password, $subuser->password->value())) {
|
||||
if ($subuser->isTwoFactorEnabled()) {
|
||||
@@ -770,9 +1101,18 @@ class subusersRoute
|
||||
// =============================
|
||||
// Subusers - List & Get (with grant visibility)
|
||||
// =============================
|
||||
$this->get('/superuser/subusers', function () {
|
||||
global $response;
|
||||
$this->requirePermission('list_subusers');
|
||||
$response->success($this->listSuperuserSubusers());
|
||||
}, [
|
||||
'list_subusers' => 'List all chauffeur access grants for superusers.',
|
||||
]);
|
||||
|
||||
$this->get('/subusers', function () {
|
||||
global $response;
|
||||
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST);
|
||||
$customerName = $this->resolveCustomerName($customerNumber);
|
||||
$includeNonEnabled = false;
|
||||
if (self::isParametersSet(['include_non_enabled'])) {
|
||||
$tmp = filter_var(self::getParameter('include_non_enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
@@ -786,13 +1126,13 @@ class subusersRoute
|
||||
);
|
||||
|
||||
$objects = (new subusers_o())
|
||||
->listObjectsWithPaginationIfSet(function ($o) use ($customerNumber) {
|
||||
->listObjectsWithPaginationIfSet(function ($o) use ($customerNumber, $customerName) {
|
||||
$subuser = (new subusers_o())->select((int)$o['id']);
|
||||
if (!$subuser->exists()) {
|
||||
return null;
|
||||
}
|
||||
$subuser->getObjectProperties();
|
||||
return $this->buildSubuserManagementPayload($subuser, $customerNumber);
|
||||
return $this->buildSubuserManagementPayload($subuser, $customerNumber, $customerName);
|
||||
}, null, [], $existsClause);
|
||||
|
||||
if (is_array($objects)) {
|
||||
@@ -873,92 +1213,52 @@ class subusersRoute
|
||||
$response->success($this->buildCurrentSubuserPayload($subuser));
|
||||
}, []);
|
||||
|
||||
$this->post('/superuser/subusers/invite', function () {
|
||||
self::requireParameters(['customer_number']);
|
||||
$this->requirePermission('add_subusers');
|
||||
$customerNumber = (int)self::getParameter('customer_number');
|
||||
self::requireType($customerNumber, self::type_int());
|
||||
$this->handleInviteSubuserForCustomer($customerNumber);
|
||||
}, [
|
||||
'add_subusers' => 'Invite or link chauffeurs for any customer (superuser).',
|
||||
]);
|
||||
|
||||
$this->post('/subusers/invite', function () {
|
||||
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD);
|
||||
$this->handleInviteSubuserForCustomer($customerNumber);
|
||||
}, [
|
||||
'add_own_subusers' => 'Invite or link chauffeurs for own customer. Subusers require node: SUBUSERS_ADD and X-Customer-Number header.',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/subusers/invite/resend', function () {
|
||||
global $response;
|
||||
|
||||
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD);
|
||||
self::requireParameters(['name', 'phone_country_code', 'phone']);
|
||||
$this->requirePermission('edit_subusers');
|
||||
self::requireParameters(['id']);
|
||||
$subuserId = (int)self::getParameter('id');
|
||||
self::requireType($subuserId, self::type_int());
|
||||
|
||||
$name = $this->normalizeOptionalString(self::getParameter('name'));
|
||||
$phoneCountryCode = (int)self::getParameter('phone_country_code');
|
||||
$phone = (int)self::getParameter('phone');
|
||||
$note = self::isParametersSet(['note']) ? $this->normalizeOptionalString(self::getParameter('note')) : null;
|
||||
$enabled = true;
|
||||
if (self::isParametersSet(['enabled'])) {
|
||||
$tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
$enabled = $tmp === null ? true : (bool)$tmp;
|
||||
$subuser = (new subusers_o())->select($subuserId);
|
||||
if (!$subuser->exists()) {
|
||||
$response->error('Subuser not found', 404);
|
||||
}
|
||||
$permissions = self::isParametersSet(['permissions'])
|
||||
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
|
||||
: null;
|
||||
$subuser->getObjectProperties();
|
||||
|
||||
if ($name === null || strlen($name) < 3 || strlen($name) > 255) {
|
||||
$response->error('Name must be between 3 and 255 characters long', 400);
|
||||
}
|
||||
self::requireType($phoneCountryCode, self::type_int());
|
||||
self::requireType($phone, self::type_int());
|
||||
self::requireMinLength('phone_country_code', 1);
|
||||
self::requireMaxLength('phone_country_code', 3);
|
||||
self::requireMinLength('phone', 4);
|
||||
self::requireMaxLength('phone', 15);
|
||||
if ($note !== null && strlen($note) > 65535) {
|
||||
$response->error('Note must be at most 65535 characters long', 400);
|
||||
}
|
||||
|
||||
$subuser = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone);
|
||||
if ($subuser === null) {
|
||||
try {
|
||||
$subuser = (new subusers_o())->add(
|
||||
null,
|
||||
null,
|
||||
$name,
|
||||
null,
|
||||
$phoneCountryCode,
|
||||
$phone
|
||||
);
|
||||
} catch (Exception $exception) {
|
||||
$response->error($exception->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
|
||||
if ($grant === null) {
|
||||
try {
|
||||
$grant = (new subuser_grants_o())->add(
|
||||
$customerNumber,
|
||||
(int)$subuser->id,
|
||||
$enabled,
|
||||
$note,
|
||||
$permissions ?? subuser_grants_o::defaultPermissions
|
||||
);
|
||||
} catch (Exception $exception) {
|
||||
$response->error('Failed to create subuser grant', 500);
|
||||
}
|
||||
} else {
|
||||
$grantUpdates = ['enabled' => $enabled];
|
||||
if (self::isParametersSet(['note'])) {
|
||||
$grantUpdates['note'] = $note;
|
||||
}
|
||||
if ($permissions !== null) {
|
||||
$grantUpdates['permissions'] = $permissions;
|
||||
}
|
||||
try {
|
||||
$grant->update($grantUpdates);
|
||||
} catch (Exception $exception) {
|
||||
$response->error('Failed to update subuser grant', 500);
|
||||
}
|
||||
$grant = (new subuser_grants_o())->select((int)$grant->id);
|
||||
$grant->getObjectProperties();
|
||||
if (!$subuser->requiresSetup()) {
|
||||
$response->error('Driver account already accepted the invitation.', 409);
|
||||
}
|
||||
|
||||
$invite = $this->issueSetupInvite($subuser);
|
||||
$response->success([
|
||||
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
|
||||
'grant' => $grant->asArray(),
|
||||
'subuser' => [
|
||||
'id' => (int)$subuser->id,
|
||||
'setup_required' => true,
|
||||
'can_resend_invite' => true,
|
||||
],
|
||||
'invite' => $invite,
|
||||
]);
|
||||
}, [
|
||||
'add_own_subusers' => 'Invite or link chauffeurs for own customer. Subusers require node: SUBUSERS_ADD and X-Customer-Number header.',
|
||||
'edit_subusers' => 'Resend chauffeur invites for any customer (superuser).',
|
||||
]);
|
||||
|
||||
$this->post('/subusers/invite/resend', function () {
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace routes;
|
||||
|
||||
use classes\db;
|
||||
use classes\economic;
|
||||
use classes\release_manager;
|
||||
use classes\router;
|
||||
use classes\shelly;
|
||||
use classes\slack;
|
||||
@@ -133,6 +134,7 @@ class workerRoute
|
||||
'timezone' => date_default_timezone_get(),
|
||||
'host' => gethostname(),
|
||||
'version' => '1.0.1',
|
||||
'api_commit_sha' => release_manager::backendCommitSha(),
|
||||
'routes' => $router->countRoutes(),
|
||||
'redis' => [
|
||||
'host' => $REDIS_CONFIG['host'],
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
it('detaches an order booking and clears the matching order link', function (): void {
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Detached Booking Customer']);
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$cashier = api_fixtures()->createUser(['display_name' => 'Detached Booking Cashier']);
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
'department_id' => $department['id'],
|
||||
]);
|
||||
$booking = api_fixtures()->createOrderBooking([
|
||||
'customer_number' => $customer['customer_number'],
|
||||
'department' => $department['id'],
|
||||
'order_id' => $order['id'],
|
||||
]);
|
||||
|
||||
api_test_runtime()->db()->query(
|
||||
'UPDATE orders SET booking_id = ' . (int)$booking['id'] . ' WHERE id = ' . (int)$order['id']
|
||||
);
|
||||
|
||||
$session = api_fixtures()->createUserSession([
|
||||
'edit_bookings',
|
||||
'department_access_' . $department['id'],
|
||||
]);
|
||||
|
||||
$response = api_client()->put('/order-bookings', [
|
||||
'id' => $booking['id'],
|
||||
'order_id' => null,
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($response->data())
|
||||
->toHaveKey('order_id')
|
||||
->and($response->data()['order_id'])
|
||||
->toBeNull();
|
||||
|
||||
$result = api_test_runtime()->db()->query(
|
||||
'SELECT ob.order_id AS booking_order_id, o.booking_id AS order_booking_id ' .
|
||||
'FROM order_bookings ob JOIN orders o ON o.id = ' . (int)$order['id'] . ' ' .
|
||||
'WHERE ob.id = ' . (int)$booking['id'] . ' LIMIT 1'
|
||||
);
|
||||
|
||||
expect($result)->not->toBeFalse();
|
||||
$row = $result->fetch_assoc();
|
||||
expect($row)->toBeArray();
|
||||
expect($row['booking_order_id'] ?? null)->toBeNull();
|
||||
expect($row['order_booking_id'] ?? null)->toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the linked order when order_id is omitted from an order booking update', function (): void {
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Still Linked Booking Customer']);
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$cashier = api_fixtures()->createUser(['display_name' => 'Still Linked Booking Cashier']);
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
'department_id' => $department['id'],
|
||||
]);
|
||||
$booking = api_fixtures()->createOrderBooking([
|
||||
'customer_number' => $customer['customer_number'],
|
||||
'department' => $department['id'],
|
||||
'order_id' => $order['id'],
|
||||
'reference' => 'ORIGINAL-BOOKING-REF',
|
||||
]);
|
||||
|
||||
api_test_runtime()->db()->query(
|
||||
'UPDATE orders SET booking_id = ' . (int)$booking['id'] . ' WHERE id = ' . (int)$order['id']
|
||||
);
|
||||
|
||||
$session = api_fixtures()->createUserSession([
|
||||
'edit_bookings',
|
||||
'department_access_' . $department['id'],
|
||||
]);
|
||||
|
||||
$response = api_client()->put('/order-bookings', [
|
||||
'id' => $booking['id'],
|
||||
'reference' => 'UPDATED-BOOKING-REF',
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect((int)($response->data()['order_id'] ?? 0))->toBe($order['id']);
|
||||
expect($response->data()['reference'] ?? null)->toBe('UPDATED-BOOKING-REF');
|
||||
|
||||
$result = api_test_runtime()->db()->query(
|
||||
'SELECT ob.order_id AS booking_order_id, o.booking_id AS order_booking_id ' .
|
||||
'FROM order_bookings ob JOIN orders o ON o.id = ' . (int)$order['id'] . ' ' .
|
||||
'WHERE ob.id = ' . (int)$booking['id'] . ' LIMIT 1'
|
||||
);
|
||||
|
||||
expect($result)->not->toBeFalse();
|
||||
$row = $result->fetch_assoc();
|
||||
expect($row)->toBeArray();
|
||||
expect((int)($row['booking_order_id'] ?? 0))->toBe($order['id']);
|
||||
expect((int)($row['order_booking_id'] ?? 0))->toBe($booking['id']);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
it('requires notes when adding the extraordinary chemistry product to an order', function (): void {
|
||||
api_test_covers('POST /order/items', 'validation');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Customer']);
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'reference' => 'NOTE-REQUIRED',
|
||||
]);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'id' => 902701,
|
||||
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
||||
'price' => 299,
|
||||
'requires_note' => 0,
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||
|
||||
api_client()
|
||||
->post('/order/items', [
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $product['id'],
|
||||
'quantity' => 1,
|
||||
'notes' => ' ',
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Notes is required for this product');
|
||||
|
||||
$response = api_client()->post('/order/items', [
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $product['id'],
|
||||
'quantity' => 1,
|
||||
'notes' => 'Graffiti removal on left side',
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($response->data()['notes'] ?? null)->toBe('Graffiti removal on left side');
|
||||
});
|
||||
|
||||
it('does not allow clearing notes for order items whose product requires notes', function (): void {
|
||||
api_test_covers('PUT /order/items', 'validation');
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Order Item Edit Customer']);
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$cashier = api_fixtures()->createUser(['display_name' => 'Order Item Cashier']);
|
||||
$order = api_fixtures()->createOrder([
|
||||
'customer_id' => $customer['customer_number'],
|
||||
'department_id' => $department['id'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
'reference' => 'NOTE-EDIT',
|
||||
]);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'id' => 902702,
|
||||
'name' => 'API Note Required Product',
|
||||
'price' => 199,
|
||||
'requires_note' => 1,
|
||||
]);
|
||||
$orderItem = api_fixtures()->createOrderItem([
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $product['id'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
'price' => 199,
|
||||
'quantity' => 1,
|
||||
'notes' => 'Initial note',
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['edit_order_items']);
|
||||
|
||||
api_client()
|
||||
->put('/order/items', [
|
||||
'id' => $orderItem['id'],
|
||||
'price' => 199,
|
||||
'quantity' => 1,
|
||||
'reference' => '',
|
||||
'notes' => '',
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Notes is required for this product');
|
||||
});
|
||||
|
||||
it('returns the extraordinary chemistry product with requires_note enabled', function (): void {
|
||||
api_test_covers('GET /products', 'happy');
|
||||
|
||||
$product = api_fixtures()->createProduct([
|
||||
'id' => 902703,
|
||||
'name' => \objects\products_o::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME,
|
||||
'price' => 299,
|
||||
'requires_note' => 0,
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||
|
||||
$response = api_client()->get('/products?id=' . $product['id'], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($response->data()['requires_note'] ?? null)->toBeTrue();
|
||||
});
|
||||
@@ -17,5 +17,7 @@ it('returns the ping contract', function (): void {
|
||||
expect($response->data())
|
||||
->toBeArray()
|
||||
->toHaveKey('message', 'pong')
|
||||
->toHaveKey('time');
|
||||
->toHaveKey('time')
|
||||
->toHaveKey('backend_version')
|
||||
->toHaveKey('api_commit_sha');
|
||||
});
|
||||
|
||||
@@ -2,6 +2,25 @@
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
function selfserve_fixture_ensure_legacy_redis_constant(): void
|
||||
{
|
||||
if (defined('redis')) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $REDIS_CONFIG;
|
||||
|
||||
$REDIS_CONFIG = [
|
||||
'host' => getenv('REDIS_CONFIG_HOST') ?: getenv('REDIS_CONFIG_DEBUG_HOST') ?: 'redis',
|
||||
'user' => getenv('REDIS_CONFIG_USER') ?: getenv('REDIS_CONFIG_DEBUG_USER') ?: 'default',
|
||||
'database' => getenv('REDIS_CONFIG_DATABASE') ?: getenv('REDIS_CONFIG_DEBUG_DATABASE') ?: '0',
|
||||
'password' => getenv('REDIS_CONFIG_PASSWORD') ?: getenv('REDIS_CONFIG_DEBUG_PASSWORD') ?: '',
|
||||
'port' => getenv('REDIS_CONFIG_PORT') ?: getenv('REDIS_CONFIG_DEBUG_PORT') ?: '6379',
|
||||
];
|
||||
|
||||
define('redis', (new \classes\redis())->connect());
|
||||
}
|
||||
|
||||
it('creates a comprehensive self-serve API scenario with demo relays', function (): void {
|
||||
$scenario = api_fixtures()->createSelfServeScenario();
|
||||
|
||||
@@ -21,3 +40,67 @@ it('creates a comprehensive self-serve API scenario with demo relays', function
|
||||
->and($session)->not->toBeNull()
|
||||
->and($session['reg'])->toBe($scenario['vehicle']['reg']);
|
||||
});
|
||||
|
||||
it('creates self-serve invoice orders on the draft customer with original customer and driver metadata attached', function (): void {
|
||||
selfserve_fixture_ensure_legacy_redis_constant();
|
||||
|
||||
$draftCustomer = api_fixtures()->createUser(['display_name' => 'Self-Serve Draft Customer']);
|
||||
$scenario = api_fixtures()->createSelfServeScenario();
|
||||
$subuser = api_fixtures()->createSubuser([
|
||||
'name' => 'Self-Serve Driver',
|
||||
'username' => 'selfserve-driver-' . $scenario['vehicle']['reg'],
|
||||
]);
|
||||
|
||||
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int');
|
||||
api_fixtures()->setModuleConfig('selfserve', 'minute_product', (string)$scenario['product']['id'], 'int');
|
||||
|
||||
$lane = (new \classes\selfserve())->lane((int)$scenario['lane']['id']);
|
||||
$lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED);
|
||||
$lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH);
|
||||
$lane->setLaneMode(\modules\selfserve\helpers\selfserve_lane_mode::MANUAL);
|
||||
$lane->setCustomerNumber((int)$scenario['customer']['customer_number']);
|
||||
$lane->setLicensePlate((string)$scenario['vehicle']['reg']);
|
||||
$lane->setWashStartTime(time() - 620);
|
||||
|
||||
$arguments = (new \modules\selfserve\classes\selfserve_lane_command_arguments())
|
||||
->setCustomerNumber((int)$scenario['customer']['customer_number'])
|
||||
->setSubuserId((int)$subuser['id']);
|
||||
|
||||
expect($lane->invoice($arguments))->toBeTrue();
|
||||
|
||||
$orderId = $lane->getLastInvoiceOrderId();
|
||||
expect($orderId)->toBeInt()->toBeGreaterThan(0);
|
||||
|
||||
$order = api_fixtures()->fetchRowById('orders', $orderId);
|
||||
$attachmentObjectType = '`orders`';
|
||||
$invoiceCollectionId = (int)($order['invoice_collection_id'] ?? 0);
|
||||
if ($invoiceCollectionId > 0) {
|
||||
api_fixtures()->cleanupDeleteById('collected_order_invoices', $invoiceCollectionId);
|
||||
}
|
||||
api_fixtures()->cleanupDeleteById('orders', $orderId);
|
||||
api_fixtures()->cleanupDeleteWhere('order_items', ['order_id' => $orderId]);
|
||||
api_fixtures()->cleanupDeleteWhere('object_attachments', ['object_type' => $attachmentObjectType, 'object_id' => $orderId]);
|
||||
|
||||
expect($order)->not->toBeNull()
|
||||
->and((int)$order['customer_id'])->toBe((int)$draftCustomer['customer_number'])
|
||||
->and((int)$order['department_id'])->toBe((int)$scenario['department']['id'])
|
||||
->and((string)$order['reg_1'])->toBe((string)$scenario['vehicle']['reg'])
|
||||
->and((int)$order['lane'])->toBe((int)$scenario['lane']['id'])
|
||||
->and($order['completed_at'])->toBeNull();
|
||||
|
||||
$db = api_test_runtime()->db();
|
||||
$result = $db->query(
|
||||
"SELECT content FROM object_attachments WHERE object_type = '{$attachmentObjectType}' AND object_id = " . (int)$orderId . ' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1'
|
||||
);
|
||||
$attachment = $result ? $result->fetch_assoc() : null;
|
||||
$content = json_decode((string)($attachment['content'] ?? ''), true);
|
||||
$metadata = is_array($content) ? ($content['other'] ?? null) : null;
|
||||
|
||||
expect($metadata)->toBeArray()
|
||||
->and($metadata['type'] ?? null)->toBe(\attachments\helpers\attachment_content::OTHER_TYPE_SELF_SERVE_WASH)
|
||||
->and((int)($metadata['customer_number'] ?? 0))->toBe((int)$scenario['customer']['customer_number'])
|
||||
->and((int)($metadata['draft_customer_number'] ?? 0))->toBe((int)$draftCustomer['customer_number'])
|
||||
->and((int)($metadata['subuser_id'] ?? 0))->toBe((int)$subuser['id'])
|
||||
->and((int)($metadata['session_id'] ?? 0))->toBe((int)$scenario['session']['id'])
|
||||
->and($metadata['subuser']['name'] ?? null)->toBe('Self-Serve Driver');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
const SUBUSER_PASSWORD_POLICY_MESSAGE = 'Password must contain at least one uppercase letter, one lowercase letter, and one number';
|
||||
|
||||
it('lists subusers when an existing grant has legacy zero permissions', function (): void {
|
||||
api_test_covers('GET /subusers', 'happy');
|
||||
|
||||
$session = api_fixtures()->createUserSession(['list_own_subusers']);
|
||||
$subuser = api_fixtures()->createSubuser([
|
||||
'name' => 'Legacy Permission Driver',
|
||||
]);
|
||||
$grantId = api_fixtures()->grantSubuser(
|
||||
$subuser['id'],
|
||||
$session['user']['customer_number'],
|
||||
['VEHICLES_LIST']
|
||||
);
|
||||
|
||||
$legacyPermissions = '0';
|
||||
$statement = api_test_runtime()->db()->prepare(
|
||||
'UPDATE `subuser_grants` SET `permissions` = ? WHERE `id` = ?'
|
||||
);
|
||||
$statement->bind_param('si', $legacyPermissions, $grantId);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
|
||||
$response = api_client()->get('/subusers?page=1&limit=5&include_non_enabled=true', $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$matchingSubusers = array_values(array_filter(
|
||||
is_array($response->data()) ? $response->data() : [],
|
||||
static fn (mixed $item): bool => is_array($item) && (int)($item['id'] ?? 0) === (int)$subuser['id']
|
||||
));
|
||||
|
||||
expect($matchingSubusers)->toHaveCount(1);
|
||||
expect($matchingSubusers[0]['grant_permissions'] ?? null)->toBe([]);
|
||||
expect($matchingSubusers[0]['permissions'] ?? null)->toBe([]);
|
||||
});
|
||||
|
||||
it('uses the same password policy for subuser setup and password auth', function (): void {
|
||||
api_test_covers('POST /subusers/setup', 'failure');
|
||||
api_test_covers('POST /subusers/auth/password', 'failure');
|
||||
|
||||
$subuser = api_fixtures()->createSubuser();
|
||||
|
||||
$setupResponse = api_client()->post('/subusers/setup', [
|
||||
'token' => 'policy-test-token',
|
||||
'name' => 'Policy Driver',
|
||||
'password' => 'invalidpassword',
|
||||
]);
|
||||
|
||||
$authResponse = api_client()->post('/subusers/auth/password', [
|
||||
'subuser_id' => $subuser['id'],
|
||||
'password' => 'invalidpassword',
|
||||
]);
|
||||
|
||||
$setupResponse
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage(SUBUSER_PASSWORD_POLICY_MESSAGE);
|
||||
|
||||
$authResponse
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage(SUBUSER_PASSWORD_POLICY_MESSAGE);
|
||||
});
|
||||
|
||||
it('lists chauffeur grants across customers for superusers', function (): void {
|
||||
$session = api_fixtures()->createUserSession(['list_subusers']);
|
||||
$firstCustomer = api_fixtures()->createUser([
|
||||
'display_name' => 'Fleet Customer Alpha',
|
||||
'economic_customer_name' => 'Fleet Customer Alpha',
|
||||
]);
|
||||
$secondCustomer = api_fixtures()->createUser([
|
||||
'display_name' => 'Fleet Customer Beta',
|
||||
'economic_customer_name' => 'Fleet Customer Beta',
|
||||
]);
|
||||
$firstSubuser = api_fixtures()->createSubuser(['name' => 'Alpha Driver']);
|
||||
$secondSubuser = api_fixtures()->createSubuser(['name' => 'Beta Driver']);
|
||||
$firstGrantId = api_fixtures()->grantSubuser(
|
||||
(int)$firstSubuser['id'],
|
||||
(int)$firstCustomer['customer_number'],
|
||||
['VEHICLES_LIST', 'SUBUSERS_LIST']
|
||||
);
|
||||
$secondGrantId = api_fixtures()->grantSubuser(
|
||||
(int)$secondSubuser['id'],
|
||||
(int)$secondCustomer['customer_number'],
|
||||
['BOOKINGS_LIST']
|
||||
);
|
||||
|
||||
$response = api_client()->get('/superuser/subusers?page=1&limit=20&search=Driver', $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$rows = array_values(array_filter(
|
||||
is_array($response->data()) ? $response->data() : [],
|
||||
static fn (mixed $item): bool => is_array($item)
|
||||
&& in_array((int)($item['grant_id'] ?? 0), [$firstGrantId, $secondGrantId], true)
|
||||
));
|
||||
|
||||
expect($rows)->toHaveCount(2);
|
||||
|
||||
$byGrantId = [];
|
||||
foreach ($rows as $row) {
|
||||
$byGrantId[(int)$row['grant_id']] = $row;
|
||||
}
|
||||
|
||||
expect($byGrantId[$firstGrantId]['customer_number'])->toBe((int)$firstCustomer['customer_number']);
|
||||
expect($byGrantId[$firstGrantId]['customer_name'])->toBe('Fleet Customer Alpha');
|
||||
expect($byGrantId[$firstGrantId]['grant_permissions'])->toBe(['VEHICLES_LIST', 'SUBUSERS_LIST']);
|
||||
expect($byGrantId[$secondGrantId]['customer_number'])->toBe((int)$secondCustomer['customer_number']);
|
||||
expect($byGrantId[$secondGrantId]['customer_name'])->toBe('Fleet Customer Beta');
|
||||
expect($byGrantId[$secondGrantId]['grant_permissions'])->toBe(['BOOKINGS_LIST']);
|
||||
});
|
||||
|
||||
it('lets superusers invite chauffeurs for a selected customer', function (): void {
|
||||
$session = api_fixtures()->createUserSession(['add_subusers']);
|
||||
$customer = api_fixtures()->createUser([
|
||||
'display_name' => 'Invite Target Customer',
|
||||
'economic_customer_name' => 'Invite Target Customer',
|
||||
]);
|
||||
$phone = 71000000 + ((int)$customer['customer_number'] % 1000000);
|
||||
$createdSubuserId = null;
|
||||
$createdGrantId = null;
|
||||
$setupToken = null;
|
||||
|
||||
try {
|
||||
$response = api_client()->post('/superuser/subusers/invite', [
|
||||
'customer_number' => (int)$customer['customer_number'],
|
||||
'name' => 'Invited Driver',
|
||||
'phone_country_code' => 45,
|
||||
'phone' => $phone,
|
||||
'permissions' => ['VEHICLES_LIST'],
|
||||
'note' => 'Created by superuser test',
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$payload = $response->data();
|
||||
$createdSubuserId = isset($payload['subuser']['id']) ? (int)$payload['subuser']['id'] : null;
|
||||
$createdGrantId = isset($payload['grant']['id']) ? (int)$payload['grant']['id'] : null;
|
||||
$setupToken = isset($payload['invite']['setup_token']) ? (string)$payload['invite']['setup_token'] : null;
|
||||
|
||||
expect($payload['subuser']['customer_number'] ?? null)->toBe((int)$customer['customer_number']);
|
||||
expect($payload['subuser']['name'] ?? null)->toBe('Invited Driver');
|
||||
expect($payload['subuser']['grant_permissions'] ?? null)->toBe(['VEHICLES_LIST']);
|
||||
expect($payload['grant']['note'] ?? null)->toBe('Created by superuser test');
|
||||
expect($payload['invite']['setup_link'] ?? null)->toBeString();
|
||||
} finally {
|
||||
if ($setupToken !== null && $setupToken !== '') {
|
||||
(new \objects\subusers_o())->invalidateSetupToken($setupToken);
|
||||
}
|
||||
if ($createdGrantId !== null) {
|
||||
api_test_runtime()->db()->query('DELETE FROM `subuser_grants` WHERE `id` = ' . $createdGrantId);
|
||||
}
|
||||
if ($createdSubuserId !== null) {
|
||||
api_test_runtime()->db()->query('DELETE FROM `tokens` WHERE `user_id` = ' . $createdSubuserId . " AND `type` = 'AUTH_TOKEN_SUBUSER'");
|
||||
api_test_runtime()->db()->query('DELETE FROM `subusers` WHERE `id` = ' . $createdSubuserId);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
it('returns the running API commit in worker status', function (): void {
|
||||
api_test_covers('GET /worker/status', 'happy');
|
||||
|
||||
$response = api_client()->get('/worker/status');
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($response->data())
|
||||
->toBeArray()
|
||||
->toHaveKey('api_commit_sha');
|
||||
|
||||
expect($response->data()['api_commit_sha'])
|
||||
->toBeString()
|
||||
->not->toBe('');
|
||||
});
|
||||
+20
-2
@@ -384,8 +384,7 @@ function edge_gateway_integration_context(): array
|
||||
}
|
||||
};
|
||||
|
||||
$db = new db($dbConfig);
|
||||
$db->connect();
|
||||
$db = edge_gateway_integration_wait_for_db($dbConfig);
|
||||
$GLOBALS['db'] = $db;
|
||||
|
||||
$mysqli = $db->conn();
|
||||
@@ -412,6 +411,25 @@ function edge_gateway_integration_context(): array
|
||||
];
|
||||
}
|
||||
|
||||
function edge_gateway_integration_wait_for_db(array $dbConfig): db
|
||||
{
|
||||
$deadline = microtime(true) + 60;
|
||||
$lastError = null;
|
||||
|
||||
do {
|
||||
try {
|
||||
$db = new db($dbConfig);
|
||||
$db->connect();
|
||||
return $db;
|
||||
} catch (RuntimeException $exception) {
|
||||
$lastError = $exception;
|
||||
usleep(500000);
|
||||
}
|
||||
} while (microtime(true) < $deadline);
|
||||
|
||||
throw $lastError ?? new RuntimeException('Database connection failed before a connection attempt completed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{host:string,user:string,password:string,database:string,port:int}
|
||||
*/
|
||||
|
||||
@@ -61,6 +61,8 @@ final class ApiFixtures
|
||||
'updated_at' => $attributes['updated_at'] ?? $now,
|
||||
]);
|
||||
|
||||
$this->deleteRedisPattern('perm:user:' . $userId . ':*');
|
||||
$this->deleteRedisPattern('obj_prop:users:' . $userId . ':*');
|
||||
$this->cleanup->add(function () use ($userId, $customerNumber): void {
|
||||
$this->purgeCustomerTraceData($userId, $customerNumber);
|
||||
$this->deleteRedisKey('user_id_from_customer_number_' . $customerNumber);
|
||||
@@ -70,6 +72,7 @@ final class ApiFixtures
|
||||
$this->deleteRedisKey('users_' . $userId . '_economic_customer');
|
||||
$this->deleteRedisKey('`users`_' . $userId . '_economic_customer');
|
||||
$this->deleteRedisPattern('perm:user:' . $userId . ':*');
|
||||
$this->deleteRedisPattern('obj_prop:users:' . $userId . ':*');
|
||||
});
|
||||
|
||||
$economicName = (string)($attributes['economic_customer_name'] ?? $displayName);
|
||||
@@ -535,6 +538,48 @@ final class ApiFixtures
|
||||
return ['id' => $categoryId];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createProduct(array $attributes = []): array
|
||||
{
|
||||
$categoryId = (int)($attributes['category'] ?? 0);
|
||||
if ($categoryId <= 0) {
|
||||
$category = $this->createCategory();
|
||||
$categoryId = (int)$category['id'];
|
||||
}
|
||||
|
||||
$productData = [
|
||||
'name' => (string)($attributes['name'] ?? ('API Product ' . $this->uniqueSuffix())),
|
||||
'description' => (string)($attributes['description'] ?? 'API product'),
|
||||
'price' => (int)($attributes['price'] ?? 100),
|
||||
'subscription_allowed' => (int)($attributes['subscription_allowed'] ?? 1),
|
||||
'category' => $categoryId,
|
||||
'piktogram' => $attributes['piktogram'] ?? 'truck',
|
||||
'economic_product_id' => $attributes['economic_product_id'] ?? 0,
|
||||
'apply_category_discount' => (int)($attributes['apply_category_discount'] ?? 0),
|
||||
'requires_note' => (int)($attributes['requires_note'] ?? 0),
|
||||
'is_wash' => (int)($attributes['is_wash'] ?? 0),
|
||||
'display_in_booking_form' => (int)($attributes['display_in_booking_form'] ?? 1),
|
||||
'order_priority' => (int)($attributes['order_priority'] ?? 0),
|
||||
'created_at' => $attributes['created_at'] ?? $this->now(),
|
||||
'updated_at' => $attributes['updated_at'] ?? $this->now(),
|
||||
'deleted_at' => $attributes['deleted_at'] ?? null,
|
||||
];
|
||||
if (isset($attributes['id'])) {
|
||||
$productData = ['id' => (int)$attributes['id']] + $productData;
|
||||
}
|
||||
|
||||
$productId = $this->insertRowWithExistingColumns('products', $productData);
|
||||
|
||||
$this->deleteRedisPattern('obj_prop:products:' . $productId . ':*');
|
||||
$this->cleanup->add(fn() => $this->deleteById('products', $productId));
|
||||
$this->cleanup->add(fn() => $this->deleteRedisPattern('obj_prop:products:' . $productId . ':*'));
|
||||
|
||||
return array_merge(['id' => $productId, 'category' => $categoryId], $this->fetchRowById('products', $productId) ?? []);
|
||||
}
|
||||
|
||||
public function linkDepartmentCategory(int $departmentId, int $categoryId): int
|
||||
{
|
||||
$linkId = $this->insertRow('department_categories', [
|
||||
|
||||
+11
-9
@@ -38,8 +38,9 @@ namespace {
|
||||
expect($content)->toContain("switch (\$dynamic_image_id)");
|
||||
});
|
||||
|
||||
it('accepts ordered dynamic image button tokens including reset start and zero', function (): void {
|
||||
expect(department_selfserve_tasks_o::normalizeButtonsInput('["reset",0,2,"start",5]'))->toBe([
|
||||
it('accepts ordered dynamic image button tokens including program picker reset start and zero', function (): void {
|
||||
expect(department_selfserve_tasks_o::normalizeButtonsInput('["program_picker","reset",0,2,"start",5]'))->toBe([
|
||||
'program_picker',
|
||||
'reset',
|
||||
0,
|
||||
2,
|
||||
@@ -50,7 +51,7 @@ namespace {
|
||||
$route = file_get_contents(app_path('routes/departmentLanesRoute.php'));
|
||||
expect($route)->not->toBeFalse();
|
||||
expect($route)->toContain('ordered highlighted button IDs');
|
||||
expect($route)->toContain('"reset", or "start"');
|
||||
expect($route)->toContain('"reset", "start", or "program_picker"');
|
||||
});
|
||||
|
||||
it('renders machine one dynamic image steps from the ordered button payload', function (): void {
|
||||
@@ -60,13 +61,14 @@ namespace {
|
||||
expect($content)->toContain('$deferredStartButtons = $this->drawHighlightedButtonSequence();');
|
||||
expect($content)->toContain('getOrderedHighlightedButtonTokens');
|
||||
expect($content)->toContain('normalizeHighlightedButtonToken');
|
||||
expect($content)->toContain("const BUTTON_PROGRAM_PICKER = 'program_picker'");
|
||||
expect($content)->toContain('getProgramPickerStepCoordinates');
|
||||
expect($content)->toContain('drawDeferredHighlightedButtons($deferredStartButtons)');
|
||||
expect($content)->toContain('self::BUTTON_PROGRAM_PICKER');
|
||||
|
||||
$thumbOffset = strpos($content, 'public function drawStepThumb');
|
||||
expect($thumbOffset)->not->toBeFalse();
|
||||
$thumbBody = substr($content, (int)$thumbOffset, 1800);
|
||||
expect($thumbBody)->toContain('self::IMAGE_BUTTON_HIGHLIGHTED_BLUE');
|
||||
expect($thumbBody)->not->toContain('drawStepCounterOnButton');
|
||||
expect($thumbBody)->not->toContain('only_generate_current_step');
|
||||
$setupOffset = strpos($content, 'public function setup');
|
||||
expect($setupOffset)->not->toBeFalse();
|
||||
$setupBody = substr($content, (int)$setupOffset, 1000);
|
||||
expect($setupBody)->not->toContain('drawStepThumb();');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use classes\cors_policy;
|
||||
|
||||
app_require('classes/cors_policy.php');
|
||||
|
||||
it('normalizes URL-like CORS entries to origins', function (): void {
|
||||
expect(cors_policy::normalizeOrigin('https://api-v2.truckwash.io/master/api'))
|
||||
->toBe('https://api-v2.truckwash.io');
|
||||
expect(cors_policy::normalizeOrigin('https://API-V2.TRUCKWASH.IO/canary/api/'))
|
||||
->toBe('https://api-v2.truckwash.io');
|
||||
expect(cors_policy::normalizeOrigin('https://api.truckwash.io:4433/ping'))
|
||||
->toBe('https://api.truckwash.io:4433');
|
||||
});
|
||||
|
||||
it('merges required release and existing frontend origins into configured CORS', function (): void {
|
||||
$origins = cors_policy::allowedOrigins('https://example.test/app,https://api-v2.truckwash.io/master/api');
|
||||
|
||||
expect($origins)->toContain('https://api-v2.truckwash.io');
|
||||
expect($origins)->toContain('http://localhost:5173');
|
||||
expect($origins)->toContain('https://truckwash.io');
|
||||
expect($origins)->not->toContain('https://api-v2.truckwash.io/master/api');
|
||||
});
|
||||
|
||||
it('builds credential-safe normal CORS response headers for allowed origins', function (): void {
|
||||
$headers = cors_policy::responseHeaders('http://localhost:5173', 'https://truckwash.io');
|
||||
|
||||
expect($headers['Access-Control-Allow-Origin'])->toBe('http://localhost:5173');
|
||||
expect($headers['Access-Control-Allow-Credentials'])->toBe('true');
|
||||
expect($headers['Access-Control-Allow-Methods'])->toContain('PATCH');
|
||||
expect($headers['Access-Control-Allow-Headers'])->toContain('X-Release-Trace');
|
||||
expect($headers['Access-Control-Allow-Headers'])->toContain('Cache-Control');
|
||||
expect($headers['Access-Control-Max-Age'])->toBe('86400');
|
||||
expect($headers['Vary'])->toBe('Origin');
|
||||
});
|
||||
|
||||
it('builds preflight CORS response headers for api-v2 release URLs', function (): void {
|
||||
$preflight = cors_policy::preflightResponse(
|
||||
'https://api-v2.truckwash.io/master/api',
|
||||
'https://truckwash.io'
|
||||
);
|
||||
|
||||
expect($preflight['allowed'])->toBeTrue();
|
||||
expect($preflight['status'])->toBe(200);
|
||||
expect($preflight['headers']['Access-Control-Allow-Origin'])->toBe('https://api-v2.truckwash.io');
|
||||
expect($preflight['headers']['Content-Type'])->toBe('application/json');
|
||||
expect($preflight['body'])->toBe('');
|
||||
});
|
||||
|
||||
it('rejects unknown CORS origins', function (): void {
|
||||
$headers = cors_policy::responseHeaders('https://evil.example.test', 'https://truckwash.io');
|
||||
$preflight = cors_policy::preflightResponse('https://evil.example.test', 'https://truckwash.io');
|
||||
|
||||
expect($headers)->toBe([]);
|
||||
expect($preflight['allowed'])->toBeFalse();
|
||||
expect($preflight['status'])->toBe(403);
|
||||
expect($preflight['body'])->toContain('CORS origin not allowed');
|
||||
});
|
||||
|
||||
it('reflects the request origin for wildcard CORS instead of sending credentialed wildcard headers', function (): void {
|
||||
$headers = cors_policy::responseHeaders('https://partner.example.test', '*');
|
||||
|
||||
expect($headers['Access-Control-Allow-Origin'])->toBe('https://partner.example.test');
|
||||
expect($headers['Access-Control-Allow-Credentials'])->toBe('true');
|
||||
expect($headers['Access-Control-Allow-Origin'])->not->toBe('*');
|
||||
});
|
||||
@@ -10,9 +10,8 @@ it('allows release telemetry headers at PHP-served CORS entry points', function
|
||||
];
|
||||
|
||||
$files = [
|
||||
app_path('index.php'),
|
||||
app_path('classes/cors_policy.php'),
|
||||
app_path('modules/washcertificates/index.php'),
|
||||
app_path('routes/optionsRoute.php'),
|
||||
];
|
||||
|
||||
foreach ($files as $file) {
|
||||
@@ -23,4 +22,7 @@ it('allows release telemetry headers at PHP-served CORS entry points', function
|
||||
expect($content)->toContain($header);
|
||||
}
|
||||
}
|
||||
|
||||
expect((string)file_get_contents(app_path('index.php')))->toContain('cors_policy::preflightResponse');
|
||||
expect((string)file_get_contents(app_path('routes/optionsRoute.php')))->toContain('cors_policy::preflightResponse');
|
||||
});
|
||||
|
||||
@@ -123,7 +123,120 @@ it('marks a channel ready when all release services are healthy', function (): v
|
||||
->and($channel['services'])->toHaveCount(5);
|
||||
});
|
||||
|
||||
it('reports non-default channels missing bundles, versions, and URLs as blocking missing values', function (): void {
|
||||
it('does not block channels on unhealthy data targets when data services are production shared', function (): void {
|
||||
$database = releaseStatusReadyService('database', 71);
|
||||
$database['deployment_status'] = 'reconcile_failed';
|
||||
$database['availability_state'] = 'degraded';
|
||||
|
||||
$overview = releaseStatusOverviewForTest([
|
||||
'channels' => [
|
||||
[
|
||||
'id' => 2,
|
||||
'slug' => 'beta',
|
||||
'name' => 'Beta',
|
||||
'default_channel' => false,
|
||||
'enabled' => true,
|
||||
'frontend_base_url' => 'https://beta.example.test',
|
||||
'api_base_url' => 'https://api-beta.example.test',
|
||||
'versions' => releaseStatusReadyVersions(8),
|
||||
],
|
||||
],
|
||||
'deployment_targets' => [
|
||||
['id' => 51, 'channel_id' => 2, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-beta'],
|
||||
['id' => 52, 'channel_id' => 2, 'app' => 'api', 'coolify_service_uuid' => 'api-beta'],
|
||||
],
|
||||
'service_sets' => [
|
||||
[
|
||||
'id' => 53,
|
||||
'channel_id' => 2,
|
||||
'mode' => 'attach_existing',
|
||||
'stack' => [
|
||||
'database' => $database,
|
||||
'redis' => releaseStatusReadyService('redis', 75),
|
||||
'minio' => releaseStatusReadyService('minio', 76),
|
||||
],
|
||||
],
|
||||
],
|
||||
'deployments' => [
|
||||
['id' => 61, 'channel_id' => 2, 'app' => 'frontend', 'status' => 'deployed'],
|
||||
['id' => 62, 'channel_id' => 2, 'app' => 'api', 'status' => 'deployed'],
|
||||
],
|
||||
]);
|
||||
|
||||
$channel = releaseStatusChannelBySlug($overview, 'beta');
|
||||
|
||||
expect($channel['readiness'])->toBe('ready')
|
||||
->and($channel['issues'])->toBe([])
|
||||
->and(releaseStatusServiceByKey($channel, 'database'))->toMatchArray([
|
||||
'status' => 'production_shared',
|
||||
'state' => 'ready',
|
||||
'severity' => 'ok',
|
||||
'issue_type' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks beta ready from the production frontend and API services', function (): void {
|
||||
$overview = releaseStatusOverviewForTest([
|
||||
'channels' => [
|
||||
[
|
||||
'id' => 1,
|
||||
'slug' => 'stable',
|
||||
'name' => 'Stable',
|
||||
'default_channel' => true,
|
||||
'enabled' => true,
|
||||
'frontend_base_url' => 'https://app.example.test',
|
||||
'api_base_url' => 'https://api.example.test',
|
||||
'versions' => releaseStatusReadyVersions(),
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'slug' => 'beta',
|
||||
'name' => 'Beta',
|
||||
'default_channel' => false,
|
||||
'enabled' => true,
|
||||
'versions' => [],
|
||||
'availability' => [
|
||||
'configured' => false,
|
||||
'status' => 'unconfigured',
|
||||
'missing' => ['frontend_version', 'frontend_base_url', 'api_version', 'api_base_url'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'deployment_targets' => [
|
||||
['id' => 11, 'channel_id' => 1, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-prod'],
|
||||
['id' => 12, 'channel_id' => 1, 'app' => 'api', 'coolify_service_uuid' => 'api-prod'],
|
||||
],
|
||||
'deployments' => [
|
||||
['id' => 41, 'channel_id' => 1, 'app' => 'frontend', 'status' => 'deployed'],
|
||||
['id' => 42, 'channel_id' => 1, 'app' => 'api', 'status' => 'deployed'],
|
||||
],
|
||||
]);
|
||||
|
||||
$channel = releaseStatusChannelBySlug($overview, 'beta');
|
||||
|
||||
expect($channel['readiness'])->toBe('ready')
|
||||
->and($channel['service_policy'])->toBe('production_shared')
|
||||
->and($channel['service_channel_slug'])->toBe('stable')
|
||||
->and($channel['missing_values'])->toBe([])
|
||||
->and($channel['availability'])->toMatchArray([
|
||||
'configured' => true,
|
||||
'missing' => [],
|
||||
'frontend_base_url' => 'https://app.example.test',
|
||||
'api_base_url' => 'https://api.example.test',
|
||||
])
|
||||
->and(releaseStatusServiceByKey($channel, 'frontend'))->toMatchArray([
|
||||
'status' => 'production_shared',
|
||||
'service_policy' => 'production_shared',
|
||||
'service_channel_slug' => 'stable',
|
||||
])
|
||||
->and(releaseStatusServiceByKey($channel, 'api'))->toMatchArray([
|
||||
'status' => 'production_shared',
|
||||
'service_policy' => 'production_shared',
|
||||
'service_channel_slug' => 'stable',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores missing legacy bundles but still blocks on missing versions and URLs', function (): void {
|
||||
$overview = releaseStatusOverviewForTest([
|
||||
'channels' => [
|
||||
[
|
||||
@@ -150,11 +263,13 @@ it('reports non-default channels missing bundles, versions, and URLs as blocking
|
||||
|
||||
$channel = releaseStatusChannelBySlug($overview, 'beta');
|
||||
$missingLabels = array_column($channel['missing_values'], 'label');
|
||||
$missingKeys = array_column($channel['missing_values'], 'key');
|
||||
|
||||
expect($overview['state'])->toBe('blocked')
|
||||
->and($overview['totals']['critical'])->toBeGreaterThanOrEqual(5)
|
||||
->and($overview['totals']['critical'])->toBeGreaterThanOrEqual(4)
|
||||
->and($channel['readiness'])->toBe('blocked')
|
||||
->and($missingLabels)->toContain('release bundle')
|
||||
->and($missingKeys)->not->toContain('release_bundle')
|
||||
->and($missingLabels)->not->toContain('release bundle')
|
||||
->and($missingLabels)->toContain('frontend version')
|
||||
->and($missingLabels)->toContain('frontend URL')
|
||||
->and($missingLabels)->toContain('API version')
|
||||
@@ -166,6 +281,150 @@ it('reports non-default channels missing bundles, versions, and URLs as blocking
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks a branch-based channel ready without a release bundle when app versions and URLs exist', function (): void {
|
||||
$versions = releaseStatusReadyVersions(0);
|
||||
unset($versions['bundle_id'], $versions['bundle_label']);
|
||||
|
||||
$overview = releaseStatusOverviewForTest([
|
||||
'channels' => [
|
||||
[
|
||||
'id' => 2,
|
||||
'slug' => 'beta',
|
||||
'name' => 'Beta',
|
||||
'default_channel' => false,
|
||||
'enabled' => true,
|
||||
'frontend_base_url' => 'https://api-v2.truckwash.io/beta/frontend',
|
||||
'api_base_url' => 'https://api-v2.truckwash.io/beta/api',
|
||||
'versions' => $versions,
|
||||
],
|
||||
],
|
||||
'deployment_targets' => [
|
||||
['id' => 21, 'channel_id' => 2, 'app' => 'frontend', 'coolify_service_uuid' => 'frontend-beta'],
|
||||
['id' => 22, 'channel_id' => 2, 'app' => 'api', 'coolify_service_uuid' => 'api-beta'],
|
||||
],
|
||||
'deployments' => [
|
||||
['id' => 61, 'channel_id' => 2, 'app' => 'frontend', 'status' => 'deployed'],
|
||||
['id' => 62, 'channel_id' => 2, 'app' => 'api', 'status' => 'deployed'],
|
||||
],
|
||||
]);
|
||||
|
||||
$channel = releaseStatusChannelBySlug($overview, 'beta');
|
||||
|
||||
expect($overview['state'])->toBe('ready')
|
||||
->and($channel['readiness'])->toBe('ready')
|
||||
->and($channel['availability'])->toMatchArray([
|
||||
'configured' => true,
|
||||
'missing' => [],
|
||||
'status' => 'ready',
|
||||
])
|
||||
->and($channel['issues'])->toBe([])
|
||||
->and($channel['missing_values'])->toBe([]);
|
||||
});
|
||||
|
||||
it('does not report frontend or API URLs missing when target auto endpoints are resolvable', function (): void {
|
||||
$overview = releaseStatusOverviewForTest([
|
||||
'channels' => [
|
||||
[
|
||||
'id' => 2,
|
||||
'slug' => 'beta',
|
||||
'name' => 'Beta',
|
||||
'default_channel' => false,
|
||||
'enabled' => true,
|
||||
'versions' => releaseStatusReadyVersions(),
|
||||
],
|
||||
],
|
||||
'deployment_targets' => [
|
||||
[
|
||||
'id' => 21,
|
||||
'channel_id' => 2,
|
||||
'channel_slug' => 'beta',
|
||||
'app' => 'frontend',
|
||||
'repository' => 'truckwash/front-end-vue',
|
||||
'branch' => 'master',
|
||||
'coolify_service_uuid' => 'frontend-beta-service',
|
||||
'deploy_context' => [
|
||||
'endpoint_mode' => 'auto',
|
||||
'public_gateway_host' => 'gateway.example.test',
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 22,
|
||||
'channel_id' => 2,
|
||||
'channel_slug' => 'beta',
|
||||
'app' => 'api',
|
||||
'repository' => 'truckwash/backend-php',
|
||||
'branch' => 'master',
|
||||
'coolify_service_uuid' => 'api-beta-service',
|
||||
'deploy_context' => [
|
||||
'endpoint_mode' => 'auto',
|
||||
'public_gateway_host' => 'gateway.example.test',
|
||||
],
|
||||
],
|
||||
],
|
||||
'service_sets' => [
|
||||
[
|
||||
'id' => 31,
|
||||
'channel_id' => 2,
|
||||
'mode' => 'isolated_stack',
|
||||
'stack' => [
|
||||
'database' => releaseStatusReadyService('database', 41),
|
||||
'redis' => releaseStatusReadyService('redis', 42),
|
||||
'minio' => releaseStatusReadyService('minio', 43),
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$channel = releaseStatusChannelBySlug($overview, 'beta');
|
||||
$missingKeys = array_column($channel['missing_values'], 'key');
|
||||
|
||||
expect($missingKeys)->not->toContain('frontend_base_url')
|
||||
->and($missingKeys)->not->toContain('api_base_url')
|
||||
->and($channel['availability'])->toMatchArray([
|
||||
'configured' => true,
|
||||
'frontend_base_url' => 'https://gateway.example.test/beta/frontend',
|
||||
'api_base_url' => 'https://gateway.example.test/beta/api',
|
||||
])
|
||||
->and($channel['readiness'])->toBe('ready');
|
||||
});
|
||||
|
||||
it('does not surface legacy release bundle actions as readiness blockers', function (): void {
|
||||
$overview = releaseStatusOverviewForTest([
|
||||
'channels' => [
|
||||
[
|
||||
'id' => 20,
|
||||
'slug' => 'beta',
|
||||
'name' => 'Beta',
|
||||
'default_channel' => false,
|
||||
'enabled' => true,
|
||||
'frontend_base_url' => 'https://beta.example.test',
|
||||
'api_base_url' => 'https://api-beta.example.test',
|
||||
'versions' => [],
|
||||
'availability' => [
|
||||
'configured' => false,
|
||||
'status' => 'unconfigured',
|
||||
'missing' => ['release_bundle'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'bundles' => [
|
||||
['id' => 91, 'channel_id' => 20, 'status' => 'deployed', 'version_label' => 'beta-bundle'],
|
||||
['id' => 92, 'channel_id' => 20, 'status' => 'failed', 'version_label' => 'failed-bundle'],
|
||||
],
|
||||
]);
|
||||
|
||||
$channel = releaseStatusChannelBySlug($overview, 'beta');
|
||||
|
||||
expect($channel['readiness'])->toBe('ready')
|
||||
->and($channel['availability'])->toMatchArray([
|
||||
'configured' => true,
|
||||
'missing' => [],
|
||||
'status' => 'ready',
|
||||
])
|
||||
->and($channel['issues'])->toBe([])
|
||||
->and($channel['missing_values'])->toBe([]);
|
||||
});
|
||||
|
||||
it('surfaces failed latest deployments as critical promotion blockers', function (): void {
|
||||
$overview = releaseStatusOverviewForTest([
|
||||
'channels' => [
|
||||
@@ -232,6 +491,73 @@ it('surfaces failed latest deployments as critical promotion blockers', function
|
||||
'type' => 'failed_deployment',
|
||||
'deployment_id' => 57,
|
||||
'next_action' => 'Open the deployment logs and fix composer dependencies.',
|
||||
])
|
||||
->and($channel['issues'][0]['key'])->toBe('failed_deployment:3:api::57:')
|
||||
->and($channel['issues'][0]['impact'])->toContain('cannot be promoted')
|
||||
->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('retry_deployment');
|
||||
});
|
||||
|
||||
it('offers application target preparation for path routed Coolify failures', function (): void {
|
||||
$overview = releaseStatusOverviewForTest([
|
||||
'channels' => [
|
||||
[
|
||||
'id' => 30,
|
||||
'slug' => 'internal',
|
||||
'name' => 'Internal',
|
||||
'default_channel' => false,
|
||||
'enabled' => true,
|
||||
'frontend_base_url' => 'https://internal.example.test',
|
||||
'api_base_url' => 'https://api-internal.example.test',
|
||||
'versions' => releaseStatusReadyVersions(19),
|
||||
'availability' => [
|
||||
'configured' => true,
|
||||
'status' => 'ready',
|
||||
'missing' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
'deployment_targets' => [
|
||||
['id' => 81, 'channel_id' => 30, 'app' => 'frontend', 'repository' => 'truckwash/front-end-vue', 'coolify_service_uuid' => 'frontend-internal'],
|
||||
['id' => 82, 'channel_id' => 30, 'app' => 'api', 'repository' => 'truckwash/backend-php', 'coolify_service_uuid' => 'legacy-api-service'],
|
||||
],
|
||||
'service_sets' => [
|
||||
[
|
||||
'id' => 83,
|
||||
'channel_id' => 30,
|
||||
'mode' => 'isolated_stack',
|
||||
'stack' => [
|
||||
'database' => releaseStatusReadyService('database', 84),
|
||||
'redis' => releaseStatusReadyService('redis', 85),
|
||||
'minio' => releaseStatusReadyService('minio', 86),
|
||||
],
|
||||
],
|
||||
],
|
||||
'deployments' => [
|
||||
[
|
||||
'id' => 87,
|
||||
'channel_id' => 30,
|
||||
'app' => 'api',
|
||||
'status' => 'failed',
|
||||
'failure_summary' => [
|
||||
'root_cause' => 'Path-routed release targets require a Coolify application resource with StripPrefix labels.',
|
||||
'next_action' => 'Set coolify_resource_type=application or migrate this target before deploying.',
|
||||
],
|
||||
],
|
||||
['id' => 88, 'channel_id' => 30, 'app' => 'frontend', 'status' => 'deployed'],
|
||||
],
|
||||
]);
|
||||
|
||||
$channel = releaseStatusChannelBySlug($overview, 'internal');
|
||||
$actions = $channel['issues'][0]['actions'];
|
||||
$actionIds = array_column($actions, 'id');
|
||||
$prepareAction = $actions[array_search('prepare_application_target', $actionIds, true)];
|
||||
|
||||
expect($actionIds)->toContain('retry_deployment')
|
||||
->and($actionIds)->toContain('prepare_application_target')
|
||||
->and($prepareAction)->toMatchArray([
|
||||
'requires_confirmation' => true,
|
||||
'requires_input' => false,
|
||||
'disabled_reason' => '',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -289,7 +615,8 @@ it('flags isolated stacks that are missing database Redis and MinIO services', f
|
||||
'severity' => 'critical',
|
||||
'issue_type' => 'missing_value',
|
||||
'missing_key' => 'minio_service',
|
||||
]);
|
||||
])
|
||||
->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('complete_data_services');
|
||||
});
|
||||
|
||||
it('maps degraded Coolify targets and reconcile failures to release service issues', function (): void {
|
||||
@@ -346,5 +673,8 @@ it('maps degraded Coolify targets and reconcile failures to release service issu
|
||||
'severity' => 'critical',
|
||||
'type' => 'service_unhealthy',
|
||||
'service_key' => 'database',
|
||||
]);
|
||||
])
|
||||
->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('reconcile_coolify_target')
|
||||
->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('redeploy_coolify_target')
|
||||
->and(array_column($channel['issues'][0]['actions'], 'id'))->toContain('restart_coolify_target');
|
||||
});
|
||||
|
||||
@@ -39,6 +39,63 @@ it('verifies GitHub sha256 webhook signatures', function (): void {
|
||||
expect(release_manager::verifyGithubSignature('', $payload, $signature))->toBeFalse();
|
||||
});
|
||||
|
||||
it('verifies CI release gate bearer tokens from dedicated release credentials', function (): void {
|
||||
$previous = getenv('RELEASE_MANAGER_GATE_TOKEN');
|
||||
|
||||
try {
|
||||
putenv('RELEASE_MANAGER_GATE_TOKEN=release-gate-secret');
|
||||
$_SERVER['RELEASE_MANAGER_GATE_TOKEN'] = 'release-gate-secret';
|
||||
|
||||
$manager = new release_manager();
|
||||
|
||||
expect($manager->verifyReleaseGateToken('release-gate-secret'))->toBeTrue();
|
||||
expect($manager->verifyReleaseGateToken('wrong-secret'))->toBeFalse();
|
||||
expect($manager->verifyReleaseGateToken(''))->toBeFalse();
|
||||
} finally {
|
||||
if ($previous === false) {
|
||||
putenv('RELEASE_MANAGER_GATE_TOKEN');
|
||||
unset($_SERVER['RELEASE_MANAGER_GATE_TOKEN']);
|
||||
} else {
|
||||
putenv('RELEASE_MANAGER_GATE_TOKEN=' . $previous);
|
||||
$_SERVER['RELEASE_MANAGER_GATE_TOKEN'] = $previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes app-specific release gate auto-sync metadata', function (): void {
|
||||
$manager = new release_manager();
|
||||
$normalizeGate = new ReflectionMethod(release_manager::class, 'normalizeReleaseGateInput');
|
||||
$normalizeGate->setAccessible(true);
|
||||
$appMatches = new ReflectionMethod(release_manager::class, 'releaseGateAppMatches');
|
||||
$appMatches->setAccessible(true);
|
||||
|
||||
$gate = $normalizeGate->invoke($manager, [
|
||||
'channel_slug' => 'stable',
|
||||
'app' => 'api',
|
||||
'repository' => 'https://github.com/copenhagentruckwash/api.git',
|
||||
'branch' => 'master',
|
||||
'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8',
|
||||
'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123',
|
||||
'auto_sync' => true,
|
||||
], ['slug' => 'stable']);
|
||||
|
||||
expect($gate)->toMatchArray([
|
||||
'channel_slug' => 'stable',
|
||||
'route_slug' => 'master',
|
||||
'app' => 'api',
|
||||
'repository' => 'copenhagentruckwash/api',
|
||||
'branch' => 'master',
|
||||
'expected_commit' => 'd52ceb85138740c45f20cda9b7ed9b7a21f0d4e8',
|
||||
'workflow_url' => 'https://github.com/copenhagentruckwash/api/actions/runs/123',
|
||||
'auto_sync' => true,
|
||||
]);
|
||||
|
||||
expect($appMatches->invoke($manager, ['app' => 'api'], 'api'))->toBeTrue();
|
||||
expect($appMatches->invoke($manager, ['apps' => ['frontend', 'api']], 'api'))->toBeTrue();
|
||||
expect($appMatches->invoke($manager, [], 'frontend'))->toBeTrue();
|
||||
expect($appMatches->invoke($manager, [], 'api'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('normalizes GitHub repository identifiers for private repository access checks', function (): void {
|
||||
expect(release_manager::normalizeGithubRepositoryName('truckwash/backend-php'))->toBe('truckwash/backend-php');
|
||||
expect(release_manager::normalizeGithubRepositoryName('https://github.com/truckwash/front-end-vue.git'))->toBe('truckwash/front-end-vue');
|
||||
@@ -250,6 +307,7 @@ it('builds explicit Coolify application route labels for release API targets', f
|
||||
->and($payload['force_domain_override'])->toBeTrue()
|
||||
->and($labels)->toContain('custom.keep=true')
|
||||
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)')
|
||||
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.priority=1001')
|
||||
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt')
|
||||
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io')
|
||||
->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080');
|
||||
@@ -328,7 +386,279 @@ it('does not treat an existing Coolify service as an application just because a
|
||||
], 'existing-application-uuid'))->toBe('application');
|
||||
});
|
||||
|
||||
it('keeps release branch services out of the production Coolify environment except beta', function (): void {
|
||||
it('auto-prepares path-routed release targets for Coolify application creation', function (): void {
|
||||
$manager = new release_manager();
|
||||
$needsApplication = new ReflectionMethod(release_manager::class, 'releaseTargetNeedsApplicationAutoCreate');
|
||||
$needsApplication->setAccessible(true);
|
||||
|
||||
$target = [
|
||||
'id' => 42,
|
||||
'channel_slug' => 'stable',
|
||||
'app' => 'api',
|
||||
'coolify_instance_id' => 7,
|
||||
'coolify_service_uuid' => '',
|
||||
'deploy_context_json' => null,
|
||||
];
|
||||
|
||||
expect($needsApplication->invoke($manager, $target, []))->toBeTrue();
|
||||
expect($needsApplication->invoke($manager, array_replace($target, [
|
||||
'coolify_service_uuid' => 'existing-service',
|
||||
]), []))->toBeFalse();
|
||||
expect($needsApplication->invoke($manager, array_replace($target, [
|
||||
'coolify_instance_id' => null,
|
||||
]), []))->toBeFalse();
|
||||
expect($needsApplication->invoke($manager, $target, [
|
||||
'coolify_auto_create' => true,
|
||||
]))->toBeFalse();
|
||||
});
|
||||
|
||||
it('offers application target preparation for missing Coolify service creation failures', function (): void {
|
||||
$needsApplicationAction = new ReflectionMethod(release_manager::class, 'releaseStatusIssueNeedsApplicationTarget');
|
||||
$needsApplicationAction->setAccessible(true);
|
||||
|
||||
expect($needsApplicationAction->invoke(null, [
|
||||
'message' => 'API deployment failed before activation.',
|
||||
'next_action' => 'Select an existing Coolify service or enable Coolify service creation before deployment.',
|
||||
]))->toBeTrue();
|
||||
expect($needsApplicationAction->invoke(null, [
|
||||
'message' => 'Path-routed release targets require a Coolify application resource with StripPrefix labels.',
|
||||
'next_action' => 'Migrate this target before deploying.',
|
||||
]))->toBeTrue();
|
||||
expect($needsApplicationAction->invoke(null, [
|
||||
'message' => 'Release gate failed.',
|
||||
'next_action' => 'Run live smoke tests before promotion.',
|
||||
]))->toBeFalse();
|
||||
});
|
||||
|
||||
it('builds API Coolify runtime environment from allowed process variables', function (): void {
|
||||
$keys = ['CONFIG_DB_HOST', 'CONFIG_DB_PASSWORD', 'EDGE_BROKER_URL', 'CORS', 'PATH'];
|
||||
$previous = [];
|
||||
foreach ($keys as $key) {
|
||||
$previous[$key] = getenv($key);
|
||||
}
|
||||
|
||||
try {
|
||||
putenv('CONFIG_DB_HOST=db.example.test');
|
||||
$_ENV['CONFIG_DB_HOST'] = 'db.example.test';
|
||||
$_SERVER['CONFIG_DB_HOST'] = 'db.example.test';
|
||||
putenv('CONFIG_DB_PASSWORD=runtime-secret');
|
||||
$_ENV['CONFIG_DB_PASSWORD'] = 'runtime-secret';
|
||||
$_SERVER['CONFIG_DB_PASSWORD'] = 'runtime-secret';
|
||||
putenv('EDGE_BROKER_URL=https://edge.example.test');
|
||||
$_ENV['EDGE_BROKER_URL'] = 'https://edge.example.test';
|
||||
$_SERVER['EDGE_BROKER_URL'] = 'https://edge.example.test';
|
||||
putenv('CORS=https://truckwash.io,https://api-v2.truckwash.io/master/api');
|
||||
$_ENV['CORS'] = 'https://truckwash.io,https://api-v2.truckwash.io/master/api';
|
||||
$_SERVER['CORS'] = 'https://truckwash.io,https://api-v2.truckwash.io/master/api';
|
||||
putenv('PATH=/should/not/copy');
|
||||
$_ENV['PATH'] = '/should/not/copy';
|
||||
$_SERVER['PATH'] = '/should/not/copy';
|
||||
|
||||
$manager = new release_manager();
|
||||
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
|
||||
$runtimeEnv->setAccessible(true);
|
||||
|
||||
$env = $runtimeEnv->invoke($manager, [
|
||||
'app' => 'api',
|
||||
], [
|
||||
'coolify_env' => [
|
||||
'CONFIG_DB_HOST' => 'context-db.example.test',
|
||||
'CUSTOM_ALLOWED' => 'from-context',
|
||||
],
|
||||
]);
|
||||
|
||||
expect($env['USE_ENV'])->toBe('true');
|
||||
expect($env['CONFIG_DB_HOST'])->toBe('context-db.example.test');
|
||||
expect($env['CONFIG_DB_PASSWORD'])->toBe('runtime-secret');
|
||||
expect($env['EDGE_BROKER_URL'])->toBe('https://edge.example.test');
|
||||
expect($env['CUSTOM_ALLOWED'])->toBe('from-context');
|
||||
expect(explode(',', $env['CORS']))->toContain('https://api-v2.truckwash.io');
|
||||
expect(explode(',', $env['CORS']))->toContain('http://localhost:5173');
|
||||
expect(explode(',', $env['CORS']))->not->toContain('https://api-v2.truckwash.io/master/api');
|
||||
expect($env)->not->toHaveKey('PATH');
|
||||
} finally {
|
||||
foreach ($previous as $key => $value) {
|
||||
if ($value === false) {
|
||||
putenv($key);
|
||||
unset($_ENV[$key], $_SERVER[$key]);
|
||||
} else {
|
||||
putenv($key . '=' . $value);
|
||||
$_ENV[$key] = $value;
|
||||
$_SERVER[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves backend commit sha from API runtime environment in priority order', function (): void {
|
||||
$keys = ['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'];
|
||||
$previous = [];
|
||||
foreach ($keys as $key) {
|
||||
$previous[$key] = [
|
||||
'process' => getenv($key),
|
||||
'env_set' => array_key_exists($key, $_ENV),
|
||||
'env' => $_ENV[$key] ?? null,
|
||||
'server_set' => array_key_exists($key, $_SERVER),
|
||||
'server' => $_SERVER[$key] ?? null,
|
||||
];
|
||||
putenv($key);
|
||||
unset($_ENV[$key], $_SERVER[$key]);
|
||||
}
|
||||
|
||||
$set = static function (string $key, string $value): void {
|
||||
putenv($key . '=' . $value);
|
||||
$_ENV[$key] = $value;
|
||||
$_SERVER[$key] = $value;
|
||||
};
|
||||
|
||||
try {
|
||||
$set('API_COMMIT_SHA', 'not-a-sha');
|
||||
$set('COMMIT_SHA', 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB');
|
||||
$set('GITHUB_SHA', 'cccccccccccccccccccccccccccccccccccccccc');
|
||||
|
||||
expect(release_manager::backendCommitSha())->toBe('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
|
||||
|
||||
$set('API_COMMIT_SHA', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA');
|
||||
|
||||
expect(release_manager::backendCommitSha())->toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
||||
} finally {
|
||||
foreach ($previous as $key => $state) {
|
||||
if ($state['process'] === false) {
|
||||
putenv($key);
|
||||
} else {
|
||||
putenv($key . '=' . $state['process']);
|
||||
}
|
||||
|
||||
if ($state['env_set']) {
|
||||
$_ENV[$key] = $state['env'];
|
||||
} else {
|
||||
unset($_ENV[$key]);
|
||||
}
|
||||
|
||||
if ($state['server_set']) {
|
||||
$_SERVER[$key] = $state['server'];
|
||||
} else {
|
||||
unset($_SERVER[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('injects selected API commit into Coolify runtime env unless explicitly set', function (): void {
|
||||
$manager = new release_manager();
|
||||
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
|
||||
$runtimeEnv->setAccessible(true);
|
||||
|
||||
$selectedCommit = '1111111111111111111111111111111111111111';
|
||||
$explicitCommit = '2222222222222222222222222222222222222222';
|
||||
|
||||
$env = $runtimeEnv->invoke($manager, [
|
||||
'app' => 'api',
|
||||
'commit_sha' => $selectedCommit,
|
||||
], [
|
||||
'coolify_env' => [
|
||||
'COMMIT_SHA' => $explicitCommit,
|
||||
],
|
||||
]);
|
||||
|
||||
expect($env['API_COMMIT_SHA'])->toBe($selectedCommit);
|
||||
expect($env['COMMIT_SHA'])->toBe($explicitCommit);
|
||||
});
|
||||
|
||||
it('keeps beta API runtime environment on production database target', function (): void {
|
||||
$keys = ['CONFIG_DB_TARGET', 'CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', 'DEBUG'];
|
||||
$previous = [];
|
||||
foreach ($keys as $key) {
|
||||
$previous[$key] = getenv($key);
|
||||
}
|
||||
|
||||
try {
|
||||
putenv('CONFIG_DB_TARGET=production');
|
||||
$_ENV['CONFIG_DB_TARGET'] = 'production';
|
||||
$_SERVER['CONFIG_DB_TARGET'] = 'production';
|
||||
putenv('CONFIG_DB_HOST=prod-db.example.test');
|
||||
$_ENV['CONFIG_DB_HOST'] = 'prod-db.example.test';
|
||||
$_SERVER['CONFIG_DB_HOST'] = 'prod-db.example.test';
|
||||
putenv('CONFIG_DB_DEBUG_HOST=debug-db.example.test');
|
||||
$_ENV['CONFIG_DB_DEBUG_HOST'] = 'debug-db.example.test';
|
||||
$_SERVER['CONFIG_DB_DEBUG_HOST'] = 'debug-db.example.test';
|
||||
putenv('DEBUG=false');
|
||||
$_ENV['DEBUG'] = 'false';
|
||||
$_SERVER['DEBUG'] = 'false';
|
||||
|
||||
$manager = new release_manager();
|
||||
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
|
||||
$runtimeEnv->setAccessible(true);
|
||||
|
||||
$env = $runtimeEnv->invoke($manager, [
|
||||
'app' => 'api',
|
||||
'channel_slug' => 'beta',
|
||||
], []);
|
||||
|
||||
expect($env['CONFIG_DB_TARGET'])->toBe('production');
|
||||
expect($env['CONFIG_DB_HOST'])->toBe('prod-db.example.test');
|
||||
expect($env['DEBUG'])->toBe('false');
|
||||
expect($env['CONFIG_DB_TARGET'])->not->toBe('debug');
|
||||
} finally {
|
||||
foreach ($previous as $key => $value) {
|
||||
if ($value === false) {
|
||||
putenv($key);
|
||||
unset($_ENV[$key], $_SERVER[$key]);
|
||||
} else {
|
||||
putenv($key . '=' . $value);
|
||||
$_ENV[$key] = $value;
|
||||
$_SERVER[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('treats attach-existing service sets without data target ids as production-shared ready', function (): void {
|
||||
$manager = new release_manager();
|
||||
$status = new ReflectionMethod(release_manager::class, 'serviceSetStatus');
|
||||
$status->setAccessible(true);
|
||||
$dataTargets = ['database' => null, 'redis' => null, 'minio' => null];
|
||||
|
||||
expect($status->invoke($manager, 'attach_existing', 10, 11, $dataTargets))->toBe('ready');
|
||||
expect($status->invoke($manager, 'clone_existing', 10, 11, $dataTargets))->toBe('needs_clone_targets');
|
||||
expect($status->invoke($manager, 'fresh_empty', 10, 11, $dataTargets))->toBe('isolated_empty');
|
||||
expect($status->invoke($manager, 'isolated_stack', 10, 11, $dataTargets))->toBe('needs_isolated_targets');
|
||||
});
|
||||
|
||||
it('detects explicit data target ids so beta service sets can stay data-only', function (): void {
|
||||
$manager = new release_manager();
|
||||
$hasExplicitDataTargets = new ReflectionMethod(release_manager::class, 'serviceSetInputHasExplicitDataTargets');
|
||||
$hasExplicitDataTargets->setAccessible(true);
|
||||
|
||||
expect($hasExplicitDataTargets->invoke($manager, [
|
||||
'mode' => 'attach_existing',
|
||||
'data_source_service_set_id' => 10,
|
||||
]))->toBeFalse();
|
||||
expect($hasExplicitDataTargets->invoke($manager, [
|
||||
'data_targets' => [
|
||||
'database' => 42,
|
||||
],
|
||||
]))->toBeTrue();
|
||||
expect($hasExplicitDataTargets->invoke($manager, [
|
||||
'redis_coolify_target_id' => 43,
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows beta production-service bundles only when data services stay production-shared', function (): void {
|
||||
$manager = new release_manager();
|
||||
$assert = new ReflectionMethod(release_manager::class, 'assertBetaProductionDataPolicy');
|
||||
$assert->setAccessible(true);
|
||||
$betaChannel = ['id' => 2, 'slug' => 'beta'];
|
||||
|
||||
expect($assert->invoke($manager, $betaChannel, ['mode' => 'attach_existing']))->toBeNull();
|
||||
|
||||
foreach (['clone_existing', 'fresh_empty', 'isolated_stack'] as $mode) {
|
||||
expect(fn() => $assert->invoke($manager, $betaChannel, ['mode' => $mode]))
|
||||
->toThrow(RuntimeException::class, 'production-shared');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps release branch services out of the production Coolify environment', function (): void {
|
||||
$manager = new release_manager();
|
||||
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');
|
||||
$payloadMethod->setAccessible(true);
|
||||
@@ -367,8 +697,8 @@ it('keeps release branch services out of the production Coolify environment exce
|
||||
'default_server_uuid' => 'server-default',
|
||||
]);
|
||||
|
||||
expect($betaPayload['environment_name'])->toBe('production');
|
||||
expect($betaPayload['environment_uuid'])->toBe('env-production');
|
||||
expect($betaPayload['environment_name'])->toBe('release-beta');
|
||||
expect($betaPayload)->not->toHaveKey('environment_uuid');
|
||||
});
|
||||
|
||||
it('does not invent GHCR images for Coolify service payloads', function (): void {
|
||||
@@ -424,15 +754,20 @@ it('defines release manager schema, routes, permissions, and system-status integ
|
||||
$response = file_get_contents(app_path('classes/response.php'));
|
||||
$status = file_get_contents(app_path('classes/superuser_system_status_service.php'));
|
||||
$index = file_get_contents(app_path('index.php'));
|
||||
$corsPolicy = file_get_contents(app_path('classes/cors_policy.php'));
|
||||
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_channels');
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_versions');
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_channel_versions');
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_assignments');
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployment_targets');
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_auto_sync_events');
|
||||
expect($schema)->toContain('UNIQUE KEY uq_release_auto_sync_event');
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_service_sets');
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_deployments');
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_bundles');
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_operation_runs');
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_operation_steps');
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_replay_targets');
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_sessions');
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS release_timeline_events');
|
||||
@@ -443,9 +778,13 @@ it('defines release manager schema, routes, permissions, and system-status integ
|
||||
expect($schema)->toContain("ensureColumn('release_channel_versions', 'service_set_id'");
|
||||
expect($schema)->toContain("ensureColumn('release_channel_versions', 'bundle_id'");
|
||||
expect($schema)->toContain("ensureColumn('release_deployments', 'deployment_kind'");
|
||||
expect($schema)->toContain("ensureColumn('release_deployments', 'active_channel_app_key'");
|
||||
expect($schema)->toContain("ensureUniqueIndex('release_deployments', 'uniq_release_deployments_active_channel_app'");
|
||||
expect($schema)->toContain("ensureColumn('release_timeline_sessions', 'device_type'");
|
||||
expect($schema)->toContain("ensureIndex('release_timeline_sessions', 'idx_release_timeline_device'");
|
||||
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'enabled'");
|
||||
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'release_gate_token'");
|
||||
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'release_gate_required_for_promotion'");
|
||||
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'github_token'");
|
||||
expect($schema)->toContain("ensureModuleConfigDefault('ReleaseManager', 'github_api_url'");
|
||||
expect($schema)->toContain("['stable', 'Stable'");
|
||||
@@ -457,12 +796,18 @@ it('defines release manager schema, routes, permissions, and system-status integ
|
||||
expect($route)->toContain('/release/runtime');
|
||||
expect($route)->toContain('/release/timeline/events');
|
||||
expect($route)->toContain('/release/github/webhook');
|
||||
expect($route)->toContain('/release/gate/test-runs');
|
||||
expect($route)->toContain('releaseGateToken');
|
||||
expect($route)->toContain('/superuser/releases/config');
|
||||
expect($route)->toContain('/superuser/releases/github/repositories');
|
||||
expect($route)->toContain('/superuser/releases/github/branches');
|
||||
expect($route)->toContain('/superuser/releases/github/commits');
|
||||
expect($route)->toContain('/superuser/releases/github/test');
|
||||
expect($route)->toContain('/superuser/releases/channels');
|
||||
expect($route)->toContain('/superuser/releases/channels/{id}/sync');
|
||||
expect($route)->toContain('/superuser/releases/test-runs');
|
||||
expect($route)->toContain('/superuser/releases/operations');
|
||||
expect($route)->toContain('/superuser/releases/operations/{id}');
|
||||
expect($route)->toContain('/superuser/releases/channels/{id}/bundle');
|
||||
expect($route)->toContain('/superuser/releases/assignment-subjects');
|
||||
expect($route)->toContain('/superuser/releases/assignments');
|
||||
@@ -473,6 +818,7 @@ it('defines release manager schema, routes, permissions, and system-status integ
|
||||
expect($route)->toContain('/superuser/releases/bundles/{id}/deploy');
|
||||
expect($route)->toContain('/superuser/releases/bundles/{id}/promote');
|
||||
expect($route)->toContain('/superuser/releases/deployments');
|
||||
expect($route)->toContain('/superuser/releases/issues/actions');
|
||||
expect($route)->toContain('/superuser/releases/replay-targets');
|
||||
expect($route)->toContain('/superuser/releases/timeline/sessions');
|
||||
expect($route)->toContain('/superuser/releases/timeline/sessions/{traceId}');
|
||||
@@ -484,6 +830,17 @@ it('defines release manager schema, routes, permissions, and system-status integ
|
||||
expect($route)->toContain("requirePermission('superuser_release_manager_replay')");
|
||||
|
||||
expect($manager)->toContain('verifyGithubSignature');
|
||||
expect($manager)->toContain('verifyReleaseGateToken');
|
||||
expect($manager)->toContain('normalizeReleaseGateInput');
|
||||
expect($manager)->toContain('processReleaseGateAutoSync');
|
||||
expect($manager)->toContain('release_auto_sync_events');
|
||||
expect($manager)->toContain('require_readiness');
|
||||
expect($manager)->toContain('release-manifest.json');
|
||||
expect($manager)->toContain('static_artifact');
|
||||
expect($manager)->toContain('api_gateway');
|
||||
expect($manager)->toContain('assertReleaseGatePassedForPromotion');
|
||||
expect($manager)->toContain('release_gate_required_for_promotion');
|
||||
expect($manager)->toContain("'public_smoke_required' => true");
|
||||
expect($manager)->toContain('normalizeGithubRepositoryName');
|
||||
expect($manager)->toContain("private const DEFAULT_BRANCH = 'master'");
|
||||
expect($manager)->toContain('releaseConfig');
|
||||
@@ -512,6 +869,7 @@ it('defines release manager schema, routes, permissions, and system-status integ
|
||||
expect($manager)->toContain('searchAssignmentSubjects');
|
||||
expect($manager)->toContain('publicAssignmentSubjectSuggestion');
|
||||
expect($manager)->toContain('available_channels');
|
||||
expect($manager)->toContain("'source' => 'deployment'");
|
||||
expect($manager)->toContain('chooseRuntimeChannel');
|
||||
expect($manager)->toContain('requestedRuntimeChannelSlug');
|
||||
expect($manager)->toContain("status = 'superseded'");
|
||||
@@ -531,6 +889,9 @@ it('defines release manager schema, routes, permissions, and system-status integ
|
||||
expect($manager)->toContain("'replica_failover' => false");
|
||||
expect($manager)->toContain('deploymentCanBePromoted');
|
||||
expect($manager)->toContain('deploymentFailureSummary');
|
||||
expect($manager)->toContain('runIssueAction');
|
||||
expect($manager)->toContain('release_issue_action_attempted');
|
||||
expect($manager)->toContain('prepareReleaseIssueApplicationTarget');
|
||||
expect($manager)->toContain('coolifyProjectSuggestions');
|
||||
expect($manager)->toContain('releaseCoolifyServerUuid');
|
||||
expect($manager)->toContain('coolify_project_uuid');
|
||||
@@ -564,6 +925,7 @@ it('defines release manager schema, routes, permissions, and system-status integ
|
||||
expect($manager)->toContain('coolify_enable_ssl');
|
||||
expect($manager)->toContain('createService');
|
||||
expect($manager)->toContain('updateService');
|
||||
expect(file_get_contents(app_path('classes/coolify_api_client.php')))->toContain('updateApplicationEnvsBulk');
|
||||
expect($manager)->toContain('channel_presets');
|
||||
expect($manager)->toContain('target_presets');
|
||||
|
||||
@@ -572,10 +934,20 @@ it('defines release manager schema, routes, permissions, and system-status integ
|
||||
expect($status)->toContain("'key' => 'releasemanager'");
|
||||
expect($status)->toContain('probeReleaseManagerModule');
|
||||
expect($index)->toContain('release_manager::initializeRequestContext');
|
||||
expect($index)->toContain('X-Release-Trace');
|
||||
expect($corsPolicy)->toContain('X-Release-Trace');
|
||||
expect($manager)->toContain('X-Release-Channel');
|
||||
expect($manager)->toContain('normalizeReleaseApiIngressPath');
|
||||
expect($manager)->toContain('routeSlugForChannel');
|
||||
expect($manager)->toContain('channelSlugForRoute');
|
||||
expect($manager)->toContain('syncChannel');
|
||||
expect($manager)->toContain('runReleaseTest');
|
||||
expect($manager)->toContain('releaseTestAppsFromInput');
|
||||
expect($manager)->toContain('release_operation_runs');
|
||||
expect($manager)->toContain('active_channel_app_key');
|
||||
expect($manager)->toContain('production_shared');
|
||||
expect($manager)->toContain('Path-routed release targets require a Coolify application resource with StripPrefix labels');
|
||||
expect($manager)->toContain('$applicationPayload[\'instant_deploy\'] = false');
|
||||
expect($manager)->toContain('$servicePayload[\'instant_deploy\'] = false');
|
||||
});
|
||||
|
||||
it('captures release request context from headers and runtime query parameters', function (): void {
|
||||
@@ -657,75 +1029,78 @@ it('normalizes channel-prefixed API ingress paths before route dispatch', functi
|
||||
}
|
||||
});
|
||||
|
||||
it('allows explicit runtime selection of any enabled release channel', function (): void {
|
||||
$db = $GLOBALS['db'] ?? null;
|
||||
it('maps the public master API prefix to the stable release channel', function (): void {
|
||||
$server = $_SERVER;
|
||||
$get = $_GET;
|
||||
$context = $GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null;
|
||||
|
||||
try {
|
||||
$GLOBALS['db'] = new class {
|
||||
public function prepare(string $sql): object
|
||||
{
|
||||
return new class {
|
||||
/** @var array<int,mixed> */
|
||||
private array $params = [];
|
||||
$_SERVER['REQUEST_URI'] = '/master/api/release/runtime';
|
||||
$_GET = [];
|
||||
$GLOBALS['RELEASE_REQUEST_CONTEXT'] = [
|
||||
'trace_id' => 'trace-master',
|
||||
'requested_channel' => '',
|
||||
'frontend_version' => '',
|
||||
'backend_version' => 'test',
|
||||
'request_started_at' => date('c'),
|
||||
'original_request_uri' => '/master/api/release/runtime',
|
||||
'normalized_request_uri' => '',
|
||||
'ingress_prefix_stripped' => false,
|
||||
];
|
||||
|
||||
public function bind_param(string $types, mixed &...$params): void
|
||||
{
|
||||
$this->params = $params;
|
||||
$normalized = release_manager::normalizeReleaseApiIngressPath(['stable', 'beta', 'canary', 'internal']);
|
||||
|
||||
expect($normalized)->toMatchArray([
|
||||
'route_slug' => 'master',
|
||||
'channel_slug' => 'stable',
|
||||
'normalized_request_uri' => '/release/runtime',
|
||||
'normalized_path' => '/release/runtime',
|
||||
]);
|
||||
expect($_GET['release_channel'])->toBe('stable');
|
||||
expect($GLOBALS['RELEASE_REQUEST_CONTEXT'])->toMatchArray([
|
||||
'requested_channel' => 'stable',
|
||||
'release_route_slug' => 'master',
|
||||
'ingress_prefix_stripped' => true,
|
||||
]);
|
||||
} finally {
|
||||
$_SERVER = $server;
|
||||
$_GET = $get;
|
||||
if ($context === null) {
|
||||
unset($GLOBALS['RELEASE_REQUEST_CONTEXT']);
|
||||
} else {
|
||||
$GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context;
|
||||
}
|
||||
|
||||
public function execute(): void
|
||||
{
|
||||
}
|
||||
});
|
||||
|
||||
public function get_result(): object
|
||||
{
|
||||
$slug = (string)($this->params[0] ?? '');
|
||||
it('uses master as the public route slug for the stable release channel', function (): void {
|
||||
expect(release_manager::routeSlugForChannel('stable'))->toBe('master');
|
||||
expect(release_manager::routeSlugForChannel('beta'))->toBe('beta');
|
||||
expect(release_manager::channelSlugForRoute('master'))->toBe('stable');
|
||||
expect(release_manager::channelSlugForRoute('internal'))->toBe('internal');
|
||||
});
|
||||
|
||||
return new class($slug) {
|
||||
public function __construct(private readonly string $slug)
|
||||
{
|
||||
}
|
||||
it('ignores explicit runtime selection for channels outside the principal channel set', function (): void {
|
||||
$manager = new release_manager();
|
||||
$chooseRuntimeChannel = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel');
|
||||
$chooseRuntimeChannel->setAccessible(true);
|
||||
|
||||
public function fetch_all(int $mode): array
|
||||
{
|
||||
if ($this->slug !== 'internal') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [[
|
||||
$stable = [
|
||||
'id' => 1,
|
||||
'slug' => 'stable',
|
||||
'enabled' => 1,
|
||||
'default_channel' => 1,
|
||||
];
|
||||
$internal = [
|
||||
'id' => 4,
|
||||
'slug' => 'internal',
|
||||
'name' => 'Internal',
|
||||
'enabled' => 1,
|
||||
'default_channel' => 0,
|
||||
'deleted_at' => null,
|
||||
]];
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
];
|
||||
|
||||
$manager = new release_manager();
|
||||
$chooseRuntimeChannel = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel');
|
||||
$chooseRuntimeChannel->setAccessible(true);
|
||||
|
||||
$channel = $chooseRuntimeChannel->invoke($manager, [
|
||||
'id' => 1,
|
||||
'slug' => 'stable',
|
||||
'enabled' => 1,
|
||||
'default_channel' => 1,
|
||||
], [], 'internal');
|
||||
|
||||
expect($channel['slug'])->toBe('internal');
|
||||
} finally {
|
||||
if ($db === null) {
|
||||
unset($GLOBALS['db']);
|
||||
} else {
|
||||
$GLOBALS['db'] = $db;
|
||||
}
|
||||
}
|
||||
expect($chooseRuntimeChannel->invoke($manager, $stable, [$stable], 'internal'))->toBe($stable);
|
||||
expect($chooseRuntimeChannel->invoke($manager, $stable, [$stable, $internal], 'internal'))->toBe($internal);
|
||||
});
|
||||
|
||||
it('requires non-default release channel runtime URLs and preserves load balancer paths', function (): void {
|
||||
@@ -830,6 +1205,7 @@ it('requires non-default release channel runtime URLs and preserves load balance
|
||||
'letsencrypt'
|
||||
);
|
||||
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)');
|
||||
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.priority=1013');
|
||||
expect($labels)->toContain('traefik.http.middlewares.https-0-release-api-internal-stripprefix.stripprefix.prefixes=/internal/api');
|
||||
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.middlewares=https-0-release-api-internal-stripprefix,gzip');
|
||||
|
||||
@@ -842,11 +1218,103 @@ it('requires non-default release channel runtime URLs and preserves load balance
|
||||
|
||||
expect($methodSource)->toContain('frontend_base_url');
|
||||
expect($methodSource)->toContain('api_base_url');
|
||||
expect($methodSource)->toContain('release_bundle');
|
||||
expect($methodSource)->not->toContain("missing[] = 'release_bundle'");
|
||||
expect($methodSource)->toContain('frontend_version');
|
||||
expect($methodSource)->toContain('api_version');
|
||||
});
|
||||
|
||||
it('resolves release deployment endpoints from manual overrides, URLs, health checks, and gateway defaults', function (): void {
|
||||
$manager = new release_manager();
|
||||
$endpoint = new ReflectionMethod(release_manager::class, 'releaseDeploymentEndpoint');
|
||||
$endpoint->setAccessible(true);
|
||||
$publicUrl = new ReflectionMethod(release_manager::class, 'releaseCoolifyPublicUrl');
|
||||
$publicUrl->setAccessible(true);
|
||||
$targetPublicBaseUrl = new ReflectionMethod(release_manager::class, 'releaseTargetPublicBaseUrl');
|
||||
$targetPublicBaseUrl->setAccessible(true);
|
||||
|
||||
$manual = $endpoint->invoke($manager, [
|
||||
'app' => 'api',
|
||||
'channel_slug' => 'beta',
|
||||
'deploy_context' => [
|
||||
'endpoint_mode' => 'manual',
|
||||
'manual_endpoint_host' => 'manual-api.example.test',
|
||||
'manual_endpoint_port' => '8443',
|
||||
],
|
||||
]);
|
||||
|
||||
expect($manual)->toMatchArray([
|
||||
'mode' => 'manual',
|
||||
'status' => 'resolved',
|
||||
'host' => 'manual-api.example.test',
|
||||
'port' => 8443,
|
||||
'url' => 'https://manual-api.example.test:8443',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
expect($publicUrl->invoke($manager, ['app' => 'api', 'channel_slug' => 'beta'], [
|
||||
'endpoint_mode' => 'manual',
|
||||
'manual_endpoint_host' => 'manual-api.example.test',
|
||||
'manual_endpoint_port' => '8443',
|
||||
]))->toBe('https://manual-api.example.test:8443');
|
||||
|
||||
$fromPublicUrl = $endpoint->invoke($manager, [
|
||||
'app' => 'frontend',
|
||||
'channel_slug' => 'internal',
|
||||
'deploy_context' => [
|
||||
'endpoint_mode' => 'auto',
|
||||
'coolify_public_url' => 'https://api-v2.truckwash.io',
|
||||
],
|
||||
]);
|
||||
expect($fromPublicUrl)->toMatchArray([
|
||||
'mode' => 'auto',
|
||||
'status' => 'resolved',
|
||||
'host' => 'api-v2.truckwash.io',
|
||||
'port' => 443,
|
||||
'url' => 'https://api-v2.truckwash.io/internal/frontend',
|
||||
'source' => 'coolify_public_url',
|
||||
]);
|
||||
|
||||
$fromHealth = $endpoint->invoke($manager, [
|
||||
'app' => 'api',
|
||||
'channel_slug' => 'canary',
|
||||
'health_url' => 'https://health.example.test/canary/api/ping',
|
||||
'deploy_context' => [
|
||||
'endpoint_mode' => 'auto',
|
||||
'coolify_domain' => 'domain.example.test',
|
||||
],
|
||||
]);
|
||||
expect($fromHealth)->toMatchArray([
|
||||
'status' => 'resolved',
|
||||
'host' => 'health.example.test',
|
||||
'url' => 'https://health.example.test/canary/api',
|
||||
'source' => 'health_url',
|
||||
]);
|
||||
|
||||
$gateway = $endpoint->invoke($manager, [
|
||||
'app' => 'frontend',
|
||||
'channel_slug' => 'beta',
|
||||
'deploy_context_json' => json_encode([
|
||||
'endpoint_mode' => 'auto',
|
||||
'public_gateway_host' => 'gateway.example.test',
|
||||
]),
|
||||
]);
|
||||
expect($gateway)->toMatchArray([
|
||||
'mode' => 'auto',
|
||||
'status' => 'pending',
|
||||
'host' => 'gateway.example.test',
|
||||
'port' => 443,
|
||||
'url' => 'https://gateway.example.test/beta/frontend',
|
||||
'source' => 'auto_gateway',
|
||||
]);
|
||||
expect($targetPublicBaseUrl->invoke($manager, [
|
||||
'app' => 'frontend',
|
||||
'channel_slug' => 'beta',
|
||||
'deploy_context_json' => json_encode([
|
||||
'endpoint_mode' => 'auto',
|
||||
'public_gateway_host' => 'gateway.example.test',
|
||||
]),
|
||||
]))->toBe('https://gateway.example.test/beta/frontend');
|
||||
});
|
||||
|
||||
it('exposes release version git commit metadata for runtime channel cards', function (): void {
|
||||
$manager = new release_manager();
|
||||
$publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion');
|
||||
@@ -882,8 +1350,7 @@ it('exposes release version git commit metadata for runtime channel cards', func
|
||||
expect($version['deployed_at'])->toBe('2026-05-19 10:20:00');
|
||||
});
|
||||
|
||||
it('chooses requested runtime channels from available channels or enabled channel slugs', function (): void {
|
||||
$db = $GLOBALS['db'] ?? null;
|
||||
it('only chooses requested runtime channels from channels available to the principal', function (): void {
|
||||
$manager = new release_manager();
|
||||
$choose = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel');
|
||||
$choose->setAccessible(true);
|
||||
@@ -900,43 +1367,20 @@ it('chooses requested runtime channels from available channels or enabled channe
|
||||
'name' => 'Canary',
|
||||
'default_channel' => 0,
|
||||
];
|
||||
|
||||
try {
|
||||
$GLOBALS['db'] = new class {
|
||||
public function prepare(string $sql): object
|
||||
{
|
||||
return new class {
|
||||
public function bind_param(string $types, mixed &...$params): void
|
||||
{
|
||||
}
|
||||
|
||||
public function execute(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function get_result(): object
|
||||
{
|
||||
return new class {
|
||||
public function fetch_all(int $mode): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
$internal = [
|
||||
'id' => 3,
|
||||
'slug' => 'internal',
|
||||
'name' => 'Internal',
|
||||
'default_channel' => 0,
|
||||
'enabled' => 1,
|
||||
];
|
||||
|
||||
expect($choose->invoke($manager, $stable, [$stable, $canary], 'canary'))->toBe($canary);
|
||||
expect($choose->invoke($manager, $stable, [$stable, $canary], 'stable'))->toBe($stable);
|
||||
expect($choose->invoke($manager, $stable, [$stable, $canary], 'internal'))->toBe($stable);
|
||||
expect($choose->invoke($manager, $stable, [$stable, $canary], 'unknown'))->toBe($stable);
|
||||
expect($choose->invoke($manager, $canary, [$stable, $canary], ''))->toBe($canary);
|
||||
} finally {
|
||||
if ($db === null) {
|
||||
unset($GLOBALS['db']);
|
||||
} else {
|
||||
$GLOBALS['db'] = $db;
|
||||
}
|
||||
}
|
||||
expect($internal['enabled'])->toBe(1);
|
||||
});
|
||||
|
||||
it('normalizes release assignment subject suggestions without leaking private fields', function (): void {
|
||||
|
||||
@@ -551,8 +551,9 @@ it('wires MinIO replication through routes and bootstrap snapshots', function ()
|
||||
expect($manager)->toContain('shouldAdvanceActiveProvisionDuringRefresh');
|
||||
expect($manager)->toContain('provisionHost((string)$host[\'kind\'], (int)$host[\'id\'])');
|
||||
expect($manager)->toContain('stale targets do not keep a healthy current target below 100%');
|
||||
expect($manager)->toContain("'replicate',\n 'status',\n 'source/' . \$bucket");
|
||||
expect($manager)->toContain("'replicate',\n 'status',\n '--json',\n 'source/' . \$bucket");
|
||||
$normalizedManager = str_replace("\r\n", "\n", $manager);
|
||||
expect($normalizedManager)->toContain("'replicate',\n 'status',\n 'source/' . \$bucket");
|
||||
expect($normalizedManager)->toContain("'replicate',\n 'status',\n '--json',\n 'source/' . \$bucket");
|
||||
expect($manager)->toContain('private function minioBucketStats(');
|
||||
expect($manager)->toContain("'minio' => [");
|
||||
expect($routes)->toContain("/superuser/replication/minio");
|
||||
|
||||
@@ -68,10 +68,11 @@ it('builds the installer around the compose stack artifacts and management polli
|
||||
expect($launcherSource)->toContain('write_rollback_status "FAILED" "compose_up_failed" "$installed_version"');
|
||||
expect($launcherSource)->toContain('write_rollback_status "FAILED" "rollback_apply_failed" "$installed_version"');
|
||||
expect($launcherSource)->toContain('write_rollback_status "ROLLED_BACK" "healthcheck_failed" "$installed_version"');
|
||||
expect($composeSource)->toContain('version: "2.4"');
|
||||
expect($composeSource)->toContain('condition: service_healthy');
|
||||
expect($composeSource)->toContain("minio:\n condition: service_started");
|
||||
expect($composeSource)->toContain("mariadb:\n condition: service_started");
|
||||
$normalizedComposeSource = str_replace("\r\n", "\n", $composeSource);
|
||||
expect($normalizedComposeSource)->toContain('version: "2.4"');
|
||||
expect($normalizedComposeSource)->toContain('condition: service_healthy');
|
||||
expect($normalizedComposeSource)->toContain("minio:\n condition: service_started");
|
||||
expect($normalizedComposeSource)->toContain("mariadb:\n condition: service_started");
|
||||
expect($composeSource)->toContain('/opt/truckwash-edge-agent/runtime/control-plane-status.json');
|
||||
expect($composeSource)->toContain('$$data[\\"last_loop_at\\"]');
|
||||
expect($composeSource)->toContain('$$data[\\"last_successful_sync_at\\"]');
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
app_require('routes/moduleSelfServeRoute.php');
|
||||
app_require('modules/selfserve/classes/selfserve_lane.php');
|
||||
app_require('objects/department_lanes_o.php');
|
||||
app_require('classes/object_property.php');
|
||||
|
||||
use classes\object_property;
|
||||
use modules\selfserve\classes\selfserve_lane;
|
||||
use objects\department_lanes_o;
|
||||
use routes\moduleSelfServeRoute;
|
||||
|
||||
class SelfserveCustomerLaneAccessRouteHarness extends moduleSelfServeRoute
|
||||
{
|
||||
/** @var array<string,bool> */
|
||||
public array $permissions = [
|
||||
'list_own_department_selfserve_vehicle_conditions' => true,
|
||||
];
|
||||
public bool $laneEnabled = true;
|
||||
public bool $activeWashResult = false;
|
||||
/** @var array<int,array{department_id:int,customer_number:int}> */
|
||||
public array $activeWashChecks = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Avoid route_t request initialization in this focused unit test.
|
||||
}
|
||||
|
||||
public function hasPermission(string|\classes\permission_node $permission, int $customer_number = null): bool
|
||||
{
|
||||
$key = $permission instanceof \classes\permission_node ? (string)$permission->permission : $permission;
|
||||
return $this->permissions[$key] ?? false;
|
||||
}
|
||||
|
||||
public function canUseLane(selfserve_lane $lane, int $customer_number): bool
|
||||
{
|
||||
return $this->canCustomerUseSelfServeLane($lane, $customer_number);
|
||||
}
|
||||
|
||||
public function canUseActiveLane(selfserve_lane $lane, int $customer_number): bool
|
||||
{
|
||||
return $this->canCustomerUseActiveSelfServeLane($lane, $customer_number);
|
||||
}
|
||||
|
||||
protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool
|
||||
{
|
||||
return $this->laneEnabled;
|
||||
}
|
||||
|
||||
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool
|
||||
{
|
||||
$this->activeWashChecks[] = [
|
||||
'department_id' => $department_id,
|
||||
'customer_number' => $customer_number,
|
||||
];
|
||||
|
||||
return $this->activeWashResult;
|
||||
}
|
||||
}
|
||||
|
||||
class SelfserveCustomerLaneAccessDepartmentLaneFake extends department_lanes_o
|
||||
{
|
||||
public function __construct(int $department_id)
|
||||
{
|
||||
$this->id = -1;
|
||||
$this->department = new object_property('department_lanes', -1, 'department', 'int');
|
||||
$this->department->set($department_id);
|
||||
}
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
// Skip database bootstrap for this unit test.
|
||||
}
|
||||
}
|
||||
|
||||
class SelfserveCustomerLaneAccessLaneFake extends selfserve_lane
|
||||
{
|
||||
public ?int $fakeCustomerNumber = null;
|
||||
|
||||
public function __construct(int $department_id, ?int $customer_number = null)
|
||||
{
|
||||
$this->id = 91;
|
||||
$this->fakeCustomerNumber = $customer_number;
|
||||
$this->department_lane = new SelfserveCustomerLaneAccessDepartmentLaneFake($department_id);
|
||||
}
|
||||
|
||||
public function getCustomerNumber(): ?int
|
||||
{
|
||||
return $this->fakeCustomerNumber;
|
||||
}
|
||||
}
|
||||
|
||||
it('allows customers with own self-serve permission to use enabled self-serve lanes', function (): void {
|
||||
$route = new SelfserveCustomerLaneAccessRouteHarness();
|
||||
$lane = new SelfserveCustomerLaneAccessLaneFake(4);
|
||||
|
||||
expect($route->canUseLane($lane, 12345679))->toBeTrue();
|
||||
});
|
||||
|
||||
it('blocks customer lane mutations without own self-serve permission', function (): void {
|
||||
$route = new SelfserveCustomerLaneAccessRouteHarness();
|
||||
$route->permissions = [];
|
||||
$lane = new SelfserveCustomerLaneAccessLaneFake(4);
|
||||
|
||||
expect($route->canUseLane($lane, 12345679))->toBeFalse();
|
||||
});
|
||||
|
||||
it('blocks customer lane mutations when the lane is not operationally enabled', function (): void {
|
||||
$route = new SelfserveCustomerLaneAccessRouteHarness();
|
||||
$route->laneEnabled = false;
|
||||
$lane = new SelfserveCustomerLaneAccessLaneFake(4);
|
||||
|
||||
expect($route->canUseLane($lane, 12345679))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows active wash operations when the lane runtime belongs to the customer', function (): void {
|
||||
$route = new SelfserveCustomerLaneAccessRouteHarness();
|
||||
$lane = new SelfserveCustomerLaneAccessLaneFake(4, 12345679);
|
||||
|
||||
expect($route->canUseActiveLane($lane, 12345679))->toBeTrue();
|
||||
expect($route->activeWashChecks)->toBe([]);
|
||||
});
|
||||
|
||||
it('keeps active wash operations available if a lane is disabled after start', function (): void {
|
||||
$route = new SelfserveCustomerLaneAccessRouteHarness();
|
||||
$route->laneEnabled = false;
|
||||
$lane = new SelfserveCustomerLaneAccessLaneFake(4, 12345679);
|
||||
|
||||
expect($route->canUseActiveLane($lane, 12345679))->toBeTrue();
|
||||
expect($route->activeWashChecks)->toBe([]);
|
||||
});
|
||||
|
||||
it('falls back to active department sessions for customer active wash operations', function (): void {
|
||||
$route = new SelfserveCustomerLaneAccessRouteHarness();
|
||||
$route->activeWashResult = true;
|
||||
$lane = new SelfserveCustomerLaneAccessLaneFake(4, null);
|
||||
|
||||
expect($route->canUseActiveLane($lane, 12345679))->toBeTrue();
|
||||
expect($route->activeWashChecks)->toBe([
|
||||
[
|
||||
'department_id' => 4,
|
||||
'customer_number' => 12345679,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('blocks active wash operations for other customers', function (): void {
|
||||
$route = new SelfserveCustomerLaneAccessRouteHarness();
|
||||
$route->activeWashResult = false;
|
||||
$lane = new SelfserveCustomerLaneAccessLaneFake(4, 99999999);
|
||||
|
||||
expect($route->canUseActiveLane($lane, 12345679))->toBeFalse();
|
||||
expect($route->activeWashChecks)->toBe([
|
||||
[
|
||||
'department_id' => 4,
|
||||
'customer_number' => 12345679,
|
||||
],
|
||||
]);
|
||||
});
|
||||
@@ -24,6 +24,7 @@ class SelfserveLaneInvoiceModeBillingHarness
|
||||
public ?int $lastAddedOrderId = null;
|
||||
public ?int $lastAddedProductId = null;
|
||||
public ?int $lastAddedQuantity = null;
|
||||
public int $createdOrderContexts = 0;
|
||||
|
||||
private selfserve_lane_status $laneStatus = selfserve_lane_status::OCCUPIED;
|
||||
private selfserve_lane_mode $laneMode = selfserve_lane_mode::MANUAL;
|
||||
@@ -85,6 +86,7 @@ class SelfserveLaneInvoiceModeBillingHarness
|
||||
|
||||
protected function createInvoiceOrderContext(): orders_o
|
||||
{
|
||||
$this->createdOrderContexts++;
|
||||
$order = new SelfserveLaneInvoiceModeOrderStub();
|
||||
$order->id = 424242;
|
||||
$this->last_invoice_order_id = (int)$order->id;
|
||||
@@ -111,6 +113,7 @@ it('bills manual self-serve stop using full elapsed minutes without included-min
|
||||
expect($harness->lastAddedOrderId)->toBe(424242);
|
||||
expect($harness->lastAddedProductId)->toBe(999);
|
||||
expect($harness->lastAddedQuantity)->toBe(1);
|
||||
expect($harness->createdOrderContexts)->toBe(1);
|
||||
expect($harness->getLastInvoiceOrderId())->toBe(424242);
|
||||
});
|
||||
|
||||
@@ -126,6 +129,6 @@ it('keeps included-minute reduction for automatic mode', function (): void {
|
||||
expect($harness->lastAddedOrderId)->toBeNull();
|
||||
expect($harness->lastAddedProductId)->toBeNull();
|
||||
expect($harness->lastAddedQuantity)->toBeNull();
|
||||
expect($harness->createdOrderContexts)->toBe(0);
|
||||
expect($harness->getLastInvoiceOrderId())->toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ it('documents the all-in-one self-serve studio replacement API', function (): vo
|
||||
expect($content)->toContain('/department/selfserve/studio/simulate:');
|
||||
expect($content)->toContain('/department/selfserve/studio/path-outcomes:');
|
||||
expect($content)->toContain('/department/selfserve/studio/path-outcomes/stream:');
|
||||
expect($content)->toContain('/department/selfserve/studio/path-confirmations:');
|
||||
expect($content)->toContain('/department/selfserve/studio/publish:');
|
||||
expect($content)->toContain('/department/selfserve/studio/rollback:');
|
||||
expect($content)->toContain('/department/selfserve/studio/gateway-action:');
|
||||
@@ -86,8 +87,11 @@ it('documents the all-in-one self-serve studio replacement API', function (): vo
|
||||
expect($content)->toContain('SelfserveStudioPathOutcomesResponse:');
|
||||
expect($content)->toContain('SelfserveStudioPathResult:');
|
||||
expect($content)->toContain('SelfserveStudioPathProgress:');
|
||||
expect($content)->toContain('SelfserveStudioPathConfirmationRequest:');
|
||||
expect($content)->toContain('SelfserveStudioPathConfirmation:');
|
||||
expect($content)->toContain('projectSelfserveStudioPathOutcomes');
|
||||
expect($content)->toContain('streamSelfserveStudioPathOutcomes');
|
||||
expect($content)->toContain('confirmSelfserveStudioPath');
|
||||
expect($content)->toContain('runSelfserveStudioGatewayAction');
|
||||
});
|
||||
|
||||
|
||||
@@ -13,14 +13,29 @@ use routes\moduleSelfServeRoute;
|
||||
class SelfservePropertyGatePermissionBypassRouteHarness extends moduleSelfServeRoute
|
||||
{
|
||||
public bool $activeWashResult = false;
|
||||
/** @var array<string,bool> */
|
||||
public array $permissions = [
|
||||
'list_own_department_selfserve_vehicle_conditions' => true,
|
||||
];
|
||||
/** @var array<int,array{department_id:int,customer_number:int}> */
|
||||
public array $activeWashChecks = [];
|
||||
|
||||
public function hasPermission(string|\classes\permission_node $permission, int $customer_number = null): bool
|
||||
{
|
||||
$key = $permission instanceof \classes\permission_node ? (string)$permission->permission : $permission;
|
||||
return $this->permissions[$key] ?? false;
|
||||
}
|
||||
|
||||
public function canUsePropertyGate(selfserve_lane $lane, int $customer_number): bool
|
||||
{
|
||||
return $this->canCustomerUsePropertyGateForLane($lane, $customer_number);
|
||||
}
|
||||
|
||||
protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool
|
||||
{
|
||||
$this->activeWashChecks[] = [
|
||||
@@ -81,6 +96,16 @@ it('does not bypass property gate permissions without a positive customer number
|
||||
expect($route->activeWashChecks)->toBe([]);
|
||||
});
|
||||
|
||||
it('does not bypass property gate permissions without customer self-serve permission', function (): void {
|
||||
$route = new SelfservePropertyGatePermissionBypassRouteHarness();
|
||||
$route->activeWashResult = true;
|
||||
$route->permissions = [];
|
||||
$lane = selfserve_property_gate_permission_lane_for_department(6);
|
||||
|
||||
expect($route->canUsePropertyGate($lane, 12345679))->toBeFalse();
|
||||
expect($route->activeWashChecks)->toBe([]);
|
||||
});
|
||||
|
||||
it('does not bypass property gate permissions when the customer has no active wash in the target department', function (): void {
|
||||
$route = new SelfservePropertyGatePermissionBypassRouteHarness();
|
||||
$route->activeWashResult = false;
|
||||
|
||||
@@ -108,16 +108,19 @@ it('wires the all-in-one self-serve studio replacement endpoints', function ():
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/simulate');
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/path-outcomes');
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/path-outcomes/stream');
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/path-confirmations');
|
||||
expect($studioRoute)->toContain("ini_set('display_errors', '0')");
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/publish');
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/rollback');
|
||||
expect($studioRoute)->toContain('/department/selfserve/studio/gateway-action');
|
||||
expect($studioRoute)->toContain('projectPathOutcomes');
|
||||
expect($studioRoute)->toContain('confirmPathOutcome');
|
||||
expect($studioRoute)->toContain('modules_shelly_config');
|
||||
expect($studioRoute)->toContain('modules_selfserve_sessions_force_stop');
|
||||
|
||||
expect($studioGraph)->not->toBeFalse();
|
||||
expect($studioGraph)->toContain('department_selfserve_studio_layouts');
|
||||
expect($studioGraph)->toContain('department_selfserve_path_confirmations');
|
||||
expect($studioGraph)->toContain('buildGatewayWorkspace');
|
||||
expect($studioGraph)->toContain('runGatewayAction');
|
||||
expect($studioGraph)->toContain('layout_affects_runtime');
|
||||
@@ -166,6 +169,32 @@ it('wires machine relay status get and set endpoints', function (): void {
|
||||
expect($moduleSelfServeRoute)->toContain('buildRelayStatusResponse');
|
||||
});
|
||||
|
||||
it('wires dashboard lane machine status and Dognvask toggle endpoints', function (): void {
|
||||
$lanesRoute = file_get_contents(app_path('routes/departmentLanesRoute.php'));
|
||||
$laneObject = file_get_contents(app_path('objects/department_lanes_o.php'));
|
||||
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
|
||||
|
||||
expect($lanesRoute)->not->toBeFalse()
|
||||
->and($lanesRoute)->toContain('/department/lanes/status-toggles')
|
||||
->and($lanesRoute)->toContain('LIST_DEPARTMENT_LANE_STATUS_TOGGLES')
|
||||
->and($lanesRoute)->toContain('getDepartmentLanes($department_id)');
|
||||
|
||||
expect($laneObject)->not->toBeFalse()
|
||||
->and($laneObject)->toContain("'machine_status_enabled' => self::isOperationalStatusName(\$status)")
|
||||
->and($laneObject)->toContain("'dognvask_configured' => \$selfserve_configuration_warnings === []")
|
||||
->and($laneObject)->toContain("'dognvask_configuration_warnings' => \$selfserve_configuration_warnings")
|
||||
->and($laneObject)->toContain('public function getSelfServeConfigurationWarnings(): array')
|
||||
->and($laneObject)->toContain('relay_machine_program_picker_id')
|
||||
->and($laneObject)->toContain('machine_type_id');
|
||||
|
||||
expect($moduleSelfServeRoute)->not->toBeFalse()
|
||||
->and($moduleSelfServeRoute)->toContain("\$this->put('/modules/self-serve/lane/status'")
|
||||
->and($moduleSelfServeRoute)->toContain('modules_selfserve_lane_status_set')
|
||||
->and($moduleSelfServeRoute)->toContain('setLaneStatus($target_status)')
|
||||
->and($moduleSelfServeRoute)->toContain('selfserve_lane_status::MAINTENANCE')
|
||||
->and($moduleSelfServeRoute)->toContain('machine_status_enabled');
|
||||
});
|
||||
|
||||
it('applies Shelly transport overrides across self-serve relay side-effect routes', function (): void {
|
||||
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
|
||||
|
||||
|
||||
@@ -50,3 +50,13 @@ it('uses mysql-safe identifiers for self-serve studio virtual hardware storage',
|
||||
expect(strlen($identifier))->toBeLessThanOrEqual(64);
|
||||
}
|
||||
});
|
||||
|
||||
it('creates self-serve studio path confirmation storage', function (): void {
|
||||
$bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php'));
|
||||
|
||||
expect($bootstrapContent)->not->toBeFalse();
|
||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS department_selfserve_path_confirmations');
|
||||
expect($bootstrapContent)->toContain('path_signature VARCHAR(128) NOT NULL');
|
||||
expect($bootstrapContent)->toContain('result_signature VARCHAR(128) NOT NULL');
|
||||
expect($bootstrapContent)->toContain('idx_selfserve_path_conf_signature');
|
||||
});
|
||||
|
||||
@@ -22,6 +22,11 @@ it('keeps cleaner relay enable wired into machine relay start paths', function (
|
||||
expect($enableMachineRelayMethodOffset)->not->toBeFalse();
|
||||
$enableMachineRelayMethod = substr($washFlow, (int)$enableMachineRelayMethodOffset, 1200);
|
||||
expect($enableMachineRelayMethod)->toContain('$this->enableCleanerRelayForStartedWash($lane);');
|
||||
$cleanerEnableOffset = strpos($enableMachineRelayMethod, '$this->enableCleanerRelayForStartedWash($lane);');
|
||||
$alreadyEnabledGuardOffset = strpos($enableMachineRelayMethod, 'if ((bool)$session->machine_relay_enabled->value() === true)');
|
||||
expect($cleanerEnableOffset)->not->toBeFalse()
|
||||
->and($alreadyEnabledGuardOffset)->not->toBeFalse()
|
||||
->and($cleanerEnableOffset)->toBeLessThan($alreadyEnabledGuardOffset);
|
||||
|
||||
expect($moduleRoute)->not->toBeFalse();
|
||||
$machineEnableRouteOffset = strpos($moduleRoute, '/modules/self-serve/lane/relay/machine/enable');
|
||||
|
||||
@@ -370,6 +370,168 @@ it('keeps runtime on published v2 configs and leaves draft JSON as the studio ed
|
||||
expect($washFlowSource)->toContain("\$configSource = \$publishedConfigPayload === null ? 'legacy' : 'published';");
|
||||
expect($studioGraphSource)->toContain('$draftObject->config_json->set($config);');
|
||||
expect($studioGraphSource)->toContain('Standalone rule operations are not supported in self-serve rules v2.');
|
||||
expect($studioGraphSource)->toContain('upsert_path');
|
||||
});
|
||||
|
||||
it('upserts path editor answers into generated condition and task config rows', function (): void {
|
||||
$service = selfserve_studio_graph_without_constructor();
|
||||
$method = new ReflectionMethod(selfserve_studio_graph::class, 'applyConfigOperation');
|
||||
$method->setAccessible(true);
|
||||
$config = [
|
||||
'schema_version' => 2,
|
||||
'questions' => [
|
||||
['id' => 11, 'question' => 'Machine wash is allowed', 'order_priority' => 1],
|
||||
['id' => 12, 'question' => 'Trailer present', 'order_priority' => 2],
|
||||
],
|
||||
'conditions' => [],
|
||||
'rules' => [],
|
||||
'tasks' => [],
|
||||
'actions' => [],
|
||||
'v2_meta' => ['next_ids' => ['condition' => 100, 'task' => 200]],
|
||||
];
|
||||
|
||||
$method->invokeArgs($service, [6, &$config, [
|
||||
'action' => 'upsert_path',
|
||||
'entity' => 'path',
|
||||
'data' => [
|
||||
'path_key' => 'allowed_front',
|
||||
'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001],
|
||||
'answers' => [
|
||||
['question_id' => 11, 'value' => true],
|
||||
['question_id' => 12, 'value' => false],
|
||||
],
|
||||
'result' => [
|
||||
'machine_allowed' => true,
|
||||
'task' => 'Set program',
|
||||
'services' => ['MACHINE', 'PROGRAM_PICKER'],
|
||||
'buttons' => ['program_picker', 'reset', 2, 'start'],
|
||||
'dynamic_images_vehicle_type' => 3,
|
||||
'tasks' => [
|
||||
[
|
||||
'task' => 'Set program',
|
||||
'services' => ['MACHINE', 'PROGRAM_PICKER'],
|
||||
'buttons' => ['program_picker'],
|
||||
'dynamic_images_vehicle_type' => 3,
|
||||
],
|
||||
[
|
||||
'task' => 'Press reset',
|
||||
'services' => ['MACHINE'],
|
||||
'buttons' => ['reset'],
|
||||
],
|
||||
[
|
||||
'task' => 'Press machine button 2',
|
||||
'services' => ['MACHINE'],
|
||||
'buttons' => [2],
|
||||
],
|
||||
[
|
||||
'task' => 'Press start',
|
||||
'services' => ['MACHINE'],
|
||||
'buttons' => ['start'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]]);
|
||||
|
||||
$condition = $config['conditions'][0] ?? [];
|
||||
$tasks = array_values($config['tasks'] ?? []);
|
||||
$pathMeta = $config['v2_meta']['path_editor']['paths']['allowed_front'] ?? [];
|
||||
$validation = (new class extends selfserve_config_versioning {
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
})->validateConfig($config);
|
||||
|
||||
expect($condition['generated_by'])->toBe('path_editor')
|
||||
->and($condition['path_key'])->toBe('allowed_front')
|
||||
->and($condition['lane'])->toBe(7)
|
||||
->and($condition['product'])->toBe(2)
|
||||
->and($condition['machine_type_id'])->toBe(1001)
|
||||
->and($condition['expression']['children'])->toHaveCount(2)
|
||||
->and($condition['expression']['children'][0]['operator'])->toBe('IS_TRUE')
|
||||
->and($condition['expression']['children'][1]['operator'])->toBe('IS_FALSE')
|
||||
->and($tasks)->toHaveCount(4)
|
||||
->and(array_column($tasks, 'task'))->toBe(['Set program', 'Press reset', 'Press machine button 2', 'Press start'])
|
||||
->and(array_column($tasks, 'order_priority'))->toBe([10, 20, 30, 40])
|
||||
->and(array_column($tasks, 'gate_ref_id'))->toBe([(int)$condition['id'], (int)$condition['id'], (int)$condition['id'], (int)$condition['id']])
|
||||
->and($tasks[0]['generated_by'])->toBe('path_editor')
|
||||
->and($tasks[0]['gate_type'])->toBe('CONDITION')
|
||||
->and($tasks[0]['services'])->toBe(['MACHINE', 'PROGRAM_PICKER'])
|
||||
->and($tasks[0]['buttons'])->toBe(['program_picker'])
|
||||
->and($tasks[0]['dynamic_images_vehicle_type'])->toBe(3)
|
||||
->and($tasks[1]['buttons'])->toBe(['reset'])
|
||||
->and($tasks[2]['buttons'])->toBe([2])
|
||||
->and($tasks[3]['buttons'])->toBe(['start'])
|
||||
->and($pathMeta['condition_id'])->toBe((int)$condition['id'])
|
||||
->and($pathMeta['task_id'])->toBe((int)$tasks[0]['id'])
|
||||
->and($pathMeta['task_ids'])->toBe(array_map(static fn(array $task): int => (int)$task['id'], $tasks))
|
||||
->and($pathMeta['result']['buttons'])->toBe(['program_picker', 'reset', 2, 'start'])
|
||||
->and($pathMeta['path_signature'])->not->toBe('')
|
||||
->and($validation['valid'])->toBeTrue();
|
||||
|
||||
$method->invokeArgs($service, [6, &$config, [
|
||||
'action' => 'upsert_path',
|
||||
'entity' => 'path',
|
||||
'data' => [
|
||||
'path_key' => 'allowed_front',
|
||||
'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001],
|
||||
'answers' => [
|
||||
['question_id' => 11, 'value' => true],
|
||||
['question_id' => 12, 'value' => false],
|
||||
],
|
||||
'result' => [
|
||||
'machine_allowed' => true,
|
||||
'task' => 'Set program',
|
||||
'services' => ['MACHINE', 'PROGRAM_PICKER'],
|
||||
'buttons' => ['program_picker', 'start'],
|
||||
'dynamic_images_vehicle_type' => 3,
|
||||
'tasks' => [
|
||||
['task' => 'Set program', 'services' => ['MACHINE', 'PROGRAM_PICKER'], 'buttons' => ['program_picker'], 'dynamic_images_vehicle_type' => 3],
|
||||
['task' => 'Press start', 'services' => ['MACHINE'], 'buttons' => ['start']],
|
||||
],
|
||||
],
|
||||
],
|
||||
]]);
|
||||
|
||||
$updatedTasks = array_values(array_filter(
|
||||
$config['tasks'] ?? [],
|
||||
static fn(array $task): bool => ($task['path_key'] ?? '') === 'allowed_front'
|
||||
));
|
||||
$updatedPathMeta = $config['v2_meta']['path_editor']['paths']['allowed_front'] ?? [];
|
||||
|
||||
expect($updatedTasks)->toHaveCount(2)
|
||||
->and(array_column($updatedTasks, 'id'))->toBe([(int)$tasks[0]['id'], (int)$tasks[1]['id']])
|
||||
->and(array_column($updatedTasks, 'task'))->toBe(['Set program', 'Press start'])
|
||||
->and($updatedPathMeta['task_ids'])->toBe([(int)$tasks[0]['id'], (int)$tasks[1]['id']]);
|
||||
|
||||
$method->invokeArgs($service, [6, &$config, [
|
||||
'action' => 'upsert_path',
|
||||
'entity' => 'path',
|
||||
'data' => [
|
||||
'path_key' => 'legacy_front',
|
||||
'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001],
|
||||
'answers' => [
|
||||
['question_id' => 11, 'value' => false],
|
||||
],
|
||||
'result' => [
|
||||
'machine_allowed' => true,
|
||||
'task' => 'Legacy start',
|
||||
'services' => ['MACHINE'],
|
||||
'buttons' => ['start'],
|
||||
],
|
||||
],
|
||||
]]);
|
||||
|
||||
$legacyPathMeta = $config['v2_meta']['path_editor']['paths']['legacy_front'] ?? [];
|
||||
$legacyTask = array_values(array_filter(
|
||||
$config['tasks'] ?? [],
|
||||
static fn(array $task): bool => ($task['path_key'] ?? '') === 'legacy_front'
|
||||
))[0] ?? [];
|
||||
|
||||
expect($legacyTask['task'])->toBe('Legacy start')
|
||||
->and($legacyTask['buttons'])->toBe(['start'])
|
||||
->and($legacyPathMeta['task_id'])->toBe((int)$legacyTask['id'])
|
||||
->and($legacyPathMeta['task_ids'])->toBe([(int)$legacyTask['id']]);
|
||||
});
|
||||
|
||||
it('surfaces task attachments in studio graph, simulator, and flow responses', function (): void {
|
||||
@@ -623,12 +785,266 @@ it('builds guided simulator debug payload with blockers and canvas annotations',
|
||||
->and($debug['questions'][0]['state'])->toBe('missing')
|
||||
->and($debug['questions'][0]['answer_source'])->toBe('override')
|
||||
->and($debug['tasks'][0]['state'])->toBe('blocked')
|
||||
->and($debug['tasks'][0]['reason'])->toBe('Task Fold mirrors blocked because gate QUESTION Are mirrors folded? expected true, actual missing.')
|
||||
->and($debug['tasks'][0]['causes'][0])->toMatchArray([
|
||||
'kind' => 'question',
|
||||
'id' => 11,
|
||||
'label' => 'Are mirrors folded?',
|
||||
'expected' => true,
|
||||
'actual' => null,
|
||||
])
|
||||
->and($debug['dynamic_image_buttons'][0])->toMatchArray([
|
||||
'kind' => 'dynamic_image_button',
|
||||
'label' => '1',
|
||||
'state' => 'hidden',
|
||||
'task_id' => 41,
|
||||
])
|
||||
->and(array_column($debug['decisions'], 'kind'))->toContain('question')
|
||||
->and(array_column($debug['decisions'], 'kind'))->toContain('task')
|
||||
->and(array_column($debug['decisions'], 'kind'))->toContain('signal')
|
||||
->and(array_column($debug['decisions'], 'kind'))->toContain('dynamic_image_button')
|
||||
->and(array_column($debug['recommendations'], 'title'))->toContain('Answer required questions')
|
||||
->and(array_column($debug['recommendations'], 'title'))->toContain('Configure lane machine relay')
|
||||
->and($debug['graph_annotations']['nodes']['question:11']['state'])->toBe('warning')
|
||||
->and($debug['graph_annotations']['nodes']['lane:7']['state'])->toBe('error');
|
||||
});
|
||||
|
||||
it('uses program picker button numbers as thumb selectors in simulator debug decisions', function (): void {
|
||||
$service = selfserve_wash_flow_without_constructor();
|
||||
|
||||
$debug = $service->buildStudioDebugPayload(6, [
|
||||
'lane' => [
|
||||
'id' => 7,
|
||||
'department' => 6,
|
||||
'name' => 'Lane 7',
|
||||
'relay_machine_id' => 'M-7',
|
||||
'relay_machine_program_picker_id' => 'PICKER-7',
|
||||
],
|
||||
'machine_type' => ['id' => 1001, 'name' => 'Portal'],
|
||||
'vehicle' => null,
|
||||
'reg' => 'TEST123',
|
||||
'customer_number' => null,
|
||||
'vehicle_type_id' => 4,
|
||||
'answers' => [],
|
||||
'questions' => [],
|
||||
'tasks' => [
|
||||
['id' => 41, 'task' => 'Choose program', 'services' => ['PROGRAM_PICKER'], 'buttons' => [3], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 1],
|
||||
['id' => 42, 'task' => 'Press first button', 'services' => ['MACHINE'], 'buttons' => [0], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 2],
|
||||
],
|
||||
'allowed_services' => ['PROGRAM_PICKER', 'MACHINE'],
|
||||
'machine_available' => true,
|
||||
'all_visible_questions_answered' => true,
|
||||
'allowed' => true,
|
||||
'config_version_id' => 90,
|
||||
'config_source' => 'draft',
|
||||
'evaluation_trace' => [
|
||||
'visible_question_ids' => [],
|
||||
'visibility_condition_results' => [],
|
||||
'condition_results' => [],
|
||||
'task_gates' => [
|
||||
['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true],
|
||||
['task_id' => 42, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true],
|
||||
],
|
||||
],
|
||||
'debug_candidates' => [
|
||||
'questions' => [],
|
||||
'conditions' => [],
|
||||
'rules' => [],
|
||||
'tasks' => [
|
||||
['id' => 41, 'task' => 'Choose program', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['PROGRAM_PICKER'], 'buttons' => [3], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 1],
|
||||
['id' => 42, 'task' => 'Press first button', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'buttons' => [0], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 2],
|
||||
],
|
||||
],
|
||||
], [
|
||||
'lookups' => [
|
||||
'labels' => [
|
||||
'departments' => ['6' => 'Roskilde'],
|
||||
'lanes' => ['7' => 'Lane 7'],
|
||||
'vehicle_types' => ['4' => 'Program 4'],
|
||||
'machine_types' => ['1001' => 'Portal'],
|
||||
'tasks' => ['41' => 'Choose program', '42' => 'Press first button'],
|
||||
],
|
||||
],
|
||||
'gateway_workspace' => [
|
||||
'gateways' => [
|
||||
[
|
||||
'id' => 701,
|
||||
'label' => 'Roskilde Edge',
|
||||
'status' => 'ONLINE',
|
||||
'bindings' => [
|
||||
['relay_id' => 'PICKER-7', 'label' => 'Program picker relay', 'role' => 'PROGRAM_PICKER', 'services' => ['PROGRAM_PICKER']],
|
||||
['relay_id' => 'M-7', 'label' => 'Machine relay', 'role' => 'MACHINE', 'services' => ['MACHINE']],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$dynamicImageDecisions = array_values(array_filter(
|
||||
$debug['decisions'],
|
||||
static fn(array $decision): bool => ($decision['kind'] ?? null) === 'dynamic_image_button'
|
||||
));
|
||||
|
||||
expect(array_column($debug['dynamic_image_buttons'], 'button'))->toBe(['program_picker', 0])
|
||||
->and(array_column($debug['dynamic_image_buttons'], 'label'))->toBe(['Program picker', '0'])
|
||||
->and(array_column($dynamicImageDecisions, 'label'))->toBe(['Program picker', '0']);
|
||||
});
|
||||
|
||||
it('adds exact hidden question, skipped action, signal, and button decision causes', function (): void {
|
||||
$service = selfserve_wash_flow_without_constructor();
|
||||
|
||||
$debug = $service->buildStudioDebugPayload(6, [
|
||||
'lane' => [
|
||||
'id' => 7,
|
||||
'department' => 6,
|
||||
'name' => 'Lane 7',
|
||||
'relay_machine_id' => 'M-7',
|
||||
],
|
||||
'machine_type' => ['id' => 1001, 'name' => 'Portal'],
|
||||
'vehicle' => null,
|
||||
'reg' => 'TEST123',
|
||||
'customer_number' => null,
|
||||
'vehicle_type_id' => 2,
|
||||
'answers' => [11 => false],
|
||||
'answer_sources' => [11 => 'override'],
|
||||
'questions' => [],
|
||||
'tasks' => [],
|
||||
'allowed_services' => [],
|
||||
'machine_available' => true,
|
||||
'all_visible_questions_answered' => true,
|
||||
'allowed' => false,
|
||||
'config_version_id' => 90,
|
||||
'config_source' => 'draft',
|
||||
'evaluation_trace' => [
|
||||
'visible_question_ids' => [11],
|
||||
'visibility_condition_results' => [21 => false],
|
||||
'condition_results' => [21 => false],
|
||||
'visibility_expression_traces' => [
|
||||
21 => [
|
||||
'type' => 'condition',
|
||||
'condition_id' => 21,
|
||||
'result' => false,
|
||||
'expression' => [
|
||||
'type' => 'group',
|
||||
'operator' => 'ALL',
|
||||
'result' => false,
|
||||
'children' => [
|
||||
[
|
||||
'type' => 'predicate',
|
||||
'subject_type' => 'question',
|
||||
'subject_id' => 11,
|
||||
'operator' => 'IS_TRUE',
|
||||
'actual_value' => false,
|
||||
'result' => false,
|
||||
'reason' => 'Predicate did not pass.',
|
||||
],
|
||||
],
|
||||
'reason' => 'Group did not pass.',
|
||||
],
|
||||
],
|
||||
],
|
||||
'condition_expression_traces' => [
|
||||
21 => [
|
||||
'type' => 'condition',
|
||||
'condition_id' => 21,
|
||||
'result' => false,
|
||||
'expression' => [
|
||||
'type' => 'predicate',
|
||||
'subject_type' => 'question',
|
||||
'subject_id' => 11,
|
||||
'operator' => 'IS_TRUE',
|
||||
'actual_value' => false,
|
||||
'result' => false,
|
||||
'reason' => 'Predicate did not pass.',
|
||||
],
|
||||
],
|
||||
],
|
||||
'task_gates' => [
|
||||
['task_id' => 41, 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'satisfied' => false],
|
||||
],
|
||||
],
|
||||
'debug_candidates' => [
|
||||
'questions' => [
|
||||
['id' => 11, 'question' => 'Are mirrors folded?', 'condition_id' => null, 'order_priority' => 1],
|
||||
['id' => 12, 'question' => 'Is the lift lowered?', 'condition_id' => 21, 'order_priority' => 2],
|
||||
],
|
||||
'conditions' => [
|
||||
['id' => 21, 'name' => 'Trailer present', 'condition_id' => null],
|
||||
],
|
||||
'rules' => [
|
||||
['id' => 31, 'condition_id' => 21, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 11, 'name' => 'Mirror answer'],
|
||||
],
|
||||
'tasks' => [
|
||||
['id' => 41, 'task' => 'Fold mirrors', 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'services' => ['MACHINE'], 'buttons' => ['start'], 'order_priority' => 1],
|
||||
],
|
||||
'actions' => [
|
||||
[
|
||||
'id' => 81,
|
||||
'name' => 'Open entry on start',
|
||||
'event' => 'wash_start_command',
|
||||
'wash_mode' => 'both',
|
||||
'operation' => 'open_lane_entrance_port',
|
||||
'condition_id' => 21,
|
||||
'order_priority' => 1,
|
||||
],
|
||||
],
|
||||
'visible_answers' => [11 => false],
|
||||
],
|
||||
], [
|
||||
'lookups' => [
|
||||
'labels' => [
|
||||
'departments' => ['6' => 'Roskilde'],
|
||||
'lanes' => ['7' => 'Lane 7'],
|
||||
'vehicle_types' => ['2' => 'Forvogn'],
|
||||
'machine_types' => ['1001' => 'Portal'],
|
||||
'questions' => ['11' => 'Are mirrors folded?', '12' => 'Is the lift lowered?'],
|
||||
'conditions' => ['21' => 'Trailer present'],
|
||||
'tasks' => ['41' => 'Fold mirrors'],
|
||||
],
|
||||
],
|
||||
'gateway_workspace' => [
|
||||
'gateways' => [
|
||||
[
|
||||
'id' => 701,
|
||||
'label' => 'Roskilde Edge',
|
||||
'status' => 'ONLINE',
|
||||
'bindings' => [
|
||||
['relay_id' => 'M-7', 'label' => 'Machine relay', 'role' => 'MACHINE', 'services' => ['MACHINE']],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$hiddenQuestion = array_values(array_filter($debug['questions'], static fn(array $question): bool => (int)$question['id'] === 12))[0] ?? [];
|
||||
$taskDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'task'))[0] ?? [];
|
||||
$actionDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'action'))[0] ?? [];
|
||||
$signalDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'signal' && ($decision['state'] ?? '') === 'blocked'))[0] ?? [];
|
||||
$buttonDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'dynamic_image_button'))[0] ?? [];
|
||||
|
||||
expect($hiddenQuestion['state'])->toBe('hidden')
|
||||
->and($hiddenQuestion['reason'])->toBe('Question Is the lift lowered? hidden because visibility condition Trailer present expected true, actual false.')
|
||||
->and($debug['conditions'][0]['causes'][0])->toMatchArray([
|
||||
'kind' => 'question',
|
||||
'id' => 11,
|
||||
'label' => 'Are mirrors folded?',
|
||||
'expected' => true,
|
||||
'actual' => false,
|
||||
])
|
||||
->and($taskDecision['reason'])->toBe('Task Fold mirrors blocked because gate QUESTION Are mirrors folded? expected true, actual false.')
|
||||
->and($actionDecision['state'])->toBe('skipped')
|
||||
->and($actionDecision['causes'][0])->toMatchArray([
|
||||
'kind' => 'condition',
|
||||
'id' => 21,
|
||||
'label' => 'Trailer present',
|
||||
'expected' => true,
|
||||
'actual' => false,
|
||||
])
|
||||
->and($signalDecision['causes'][0]['reason'])->toContain('blocked')
|
||||
->and($buttonDecision['state'])->toBe('hidden')
|
||||
->and($buttonDecision['causes'][0]['label'])->toBe('Fold mirrors');
|
||||
});
|
||||
|
||||
it('projects visible question answer paths into grouped task service and signal outcomes', function (): void {
|
||||
$service = selfserve_studio_graph_without_constructor();
|
||||
$simulate = function (array $overrides): array {
|
||||
@@ -762,10 +1178,93 @@ it('projects visible question answer paths into grouped task service and signal
|
||||
->and($allowedPath['answers'])->toHaveCount(2)
|
||||
->and($allowedPath['answers'][0]['question'])->toBe('Are mirrors folded?')
|
||||
->and($allowedPath['services'])->toBe(['MACHINE'])
|
||||
->and($allowedPath['path_signature'])->not->toBe('')
|
||||
->and($allowedPath['result_signature'])->not->toBe('')
|
||||
->and($allowedPath['confirmation_status'])->toBe('unconfirmed')
|
||||
->and($allowedPath['node_ids'])->toContain('question:11')
|
||||
->and($allowedPath['node_ids'])->toContain('task:41');
|
||||
});
|
||||
|
||||
it('marks projected path confirmations confirmed or stale by stable signatures', function (): void {
|
||||
$service = selfserve_studio_graph_without_constructor();
|
||||
$simulate = static function (string $button): callable {
|
||||
return static function (array $overrides) use ($button): array {
|
||||
$answers = [];
|
||||
foreach ($overrides as $entry) {
|
||||
$answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null;
|
||||
}
|
||||
$allowed = ($answers[11] ?? null) === true;
|
||||
|
||||
return [
|
||||
'allowed' => $allowed,
|
||||
'questions' => [
|
||||
['id' => 11, 'question' => 'Machine wash is allowed', 'answer' => $answers[11] ?? null],
|
||||
],
|
||||
'tasks' => $allowed ? [
|
||||
['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => [$button]],
|
||||
] : [],
|
||||
'allowed_services' => $allowed ? ['MACHINE'] : [],
|
||||
'debug' => [
|
||||
'questions' => [
|
||||
['id' => 11, 'node_id' => 'question:11', 'label' => 'Machine wash is allowed', 'visible' => true, 'answer' => $answers[11] ?? null],
|
||||
],
|
||||
'tasks' => [
|
||||
['id' => 41, 'node_id' => 'task:41', 'label' => 'Start machine', 'active' => $allowed, 'services' => ['MACHINE'], 'buttons' => [$button], 'order_priority' => 1],
|
||||
],
|
||||
'signal_timeline' => [],
|
||||
],
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
$scope = [
|
||||
'department_id' => 6,
|
||||
'lane_id' => 7,
|
||||
'vehicle_type_id' => 2,
|
||||
'config_source' => 'draft',
|
||||
'config_version_id' => 90,
|
||||
'hardware_mode' => 'studio',
|
||||
];
|
||||
$initial = $service->projectPathOutcomesFromSimulator($simulate('start'), ['scope' => $scope]);
|
||||
$allowedPath = array_values(array_filter(
|
||||
$initial['paths'],
|
||||
static fn(array $path): bool => ($path['allowed'] ?? false) === true
|
||||
))[0] ?? [];
|
||||
|
||||
$rows = [[
|
||||
'path_signature' => $allowedPath['path_signature'],
|
||||
'result_signature' => $allowedPath['result_signature'],
|
||||
'answers' => $allowedPath['answers'],
|
||||
'result' => ['allowed' => true],
|
||||
'scope' => $scope,
|
||||
'confirmed_at' => '2026-05-27 10:00:00',
|
||||
'confirmed_by' => 9,
|
||||
]];
|
||||
$confirmed = $service->projectPathOutcomesFromSimulator($simulate('start'), [
|
||||
'scope' => $scope,
|
||||
'confirmation_rows' => $rows,
|
||||
]);
|
||||
$changed = $service->projectPathOutcomesFromSimulator($simulate('reset'), [
|
||||
'scope' => $scope,
|
||||
'confirmation_rows' => $rows,
|
||||
]);
|
||||
|
||||
$confirmedAllowed = array_values(array_filter(
|
||||
$confirmed['paths'],
|
||||
static fn(array $path): bool => ($path['allowed'] ?? false) === true
|
||||
))[0] ?? [];
|
||||
$staleAllowed = array_values(array_filter(
|
||||
$changed['paths'],
|
||||
static fn(array $path): bool => ($path['allowed'] ?? false) === true
|
||||
))[0] ?? [];
|
||||
|
||||
expect($confirmedAllowed['confirmation_status'])->toBe('confirmed')
|
||||
->and($confirmed['summary']['confirmations']['confirmed'])->toBe(1)
|
||||
->and($staleAllowed['confirmation_status'])->toBe('stale')
|
||||
->and($staleAllowed['stale_reason'])->toBe('Result changed since confirmation.')
|
||||
->and($changed['summary']['confirmations']['stale'])->toBe(1);
|
||||
});
|
||||
|
||||
it('truncates path outcome projection when the state cap is reached', function (): void {
|
||||
$service = selfserve_studio_graph_without_constructor();
|
||||
$simulate = function (array $overrides): array {
|
||||
|
||||
@@ -1,5 +1,79 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/object_property.php');
|
||||
app_require('objects/department_lanes_o.php');
|
||||
app_require('modules/selfserve/classes/selfserve_lane.php');
|
||||
app_require('modules/selfserve/classes/selfserve_wash_flow.php');
|
||||
app_require('modules/selfserve/helpers/selfserve_lane_relay.php');
|
||||
|
||||
use modules\selfserve\classes\selfserve_lane;
|
||||
use modules\selfserve\classes\selfserve_wash_flow;
|
||||
use modules\selfserve\helpers\selfserve_lane_relay;
|
||||
|
||||
class SelfserveWashCompletionRelayValueFake extends \classes\object_property
|
||||
{
|
||||
public function __construct(private readonly mixed $storedValue) {}
|
||||
|
||||
public function value(): mixed
|
||||
{
|
||||
return $this->storedValue;
|
||||
}
|
||||
}
|
||||
|
||||
class SelfserveWashCompletionDepartmentLaneFake extends \objects\department_lanes_o
|
||||
{
|
||||
public function __construct(string $machineRelayId = 'relay-machine', string $cleanerRelayId = 'relay-cleaner')
|
||||
{
|
||||
$this->relay_machine_id = new SelfserveWashCompletionRelayValueFake($machineRelayId);
|
||||
$this->relay_machine_cleaner_id = new SelfserveWashCompletionRelayValueFake($cleanerRelayId);
|
||||
$this->relay_machine_program_picker_id = new SelfserveWashCompletionRelayValueFake('');
|
||||
}
|
||||
|
||||
public function exists(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class SelfserveWashCompletionRelayLaneFake extends selfserve_lane
|
||||
{
|
||||
/** @var selfserve_lane_relay[] */
|
||||
public array $statusReads = [];
|
||||
/** @var array<int,array{0:selfserve_lane_relay,1:bool}> */
|
||||
public array $relayWrites = [];
|
||||
|
||||
public function __construct(bool $reportedOn)
|
||||
{
|
||||
$this->id = 77;
|
||||
$this->reportedOn = $reportedOn;
|
||||
$this->department_lane = new SelfserveWashCompletionDepartmentLaneFake();
|
||||
}
|
||||
|
||||
private bool $reportedOn;
|
||||
|
||||
public function getRelayStatus(selfserve_lane_relay $relay): array
|
||||
{
|
||||
$this->statusReads[] = $relay;
|
||||
return ['on' => $this->reportedOn];
|
||||
}
|
||||
|
||||
public function setRelayStatusHard(selfserve_lane_relay $relay, bool $on): bool
|
||||
{
|
||||
$this->relayWrites[] = [$relay, $on];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class SelfserveWashCompletionFlowHarness extends selfserve_wash_flow
|
||||
{
|
||||
public function __construct() {}
|
||||
|
||||
public function turnOffConfiguredRelay(selfserve_lane $lane, selfserve_lane_relay $relay): void
|
||||
{
|
||||
$this->turnOffRelayIfConfigured($lane, $relay);
|
||||
}
|
||||
}
|
||||
|
||||
it('forces machine and cleaner relays off when a self-serve wash session is completed', function (): void {
|
||||
$washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php'));
|
||||
|
||||
@@ -10,14 +84,27 @@ it('forces machine and cleaner relays off when a self-serve wash session is comp
|
||||
expect($methodOffset)->not->toBeFalse();
|
||||
$methodBody = substr($washFlow, (int)$methodOffset, 1500);
|
||||
|
||||
expect($methodBody)->toContain('$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE);');
|
||||
expect($methodBody)->toContain('$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE_CLEANER);');
|
||||
expect($methodBody)->toContain('$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE);');
|
||||
expect($methodBody)->toContain('$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE_CLEANER);');
|
||||
|
||||
$helperOffset = strpos($washFlow, 'protected function turnOffRelayIfConfiguredAndOn');
|
||||
$helperOffset = strpos($washFlow, 'protected function turnOffRelayIfConfigured');
|
||||
expect($helperOffset)->not->toBeFalse();
|
||||
$helperBody = substr($washFlow, (int)$helperOffset, 1500);
|
||||
|
||||
expect($helperBody)->toContain('$status = $lane->getRelayStatus($relay);');
|
||||
expect($helperBody)->toContain("if ((bool)(\$status['on'] ?? false) !== true)");
|
||||
expect($helperBody)->toContain('$lane->setRelayStatusHard($relay, false);');
|
||||
expect($helperBody)->not->toContain('$lane->getRelayStatus($relay)');
|
||||
});
|
||||
|
||||
it('always dispatches completion relay off for configured machine relays without a status precheck', function (): void {
|
||||
$lane = new SelfserveWashCompletionRelayLaneFake(reportedOn: false);
|
||||
$flow = new SelfserveWashCompletionFlowHarness();
|
||||
|
||||
$flow->turnOffConfiguredRelay($lane, selfserve_lane_relay::MACHINE);
|
||||
$flow->turnOffConfiguredRelay($lane, selfserve_lane_relay::MACHINE_CLEANER);
|
||||
|
||||
expect($lane->statusReads)->toBe([])
|
||||
->and($lane->relayWrites)->toBe([
|
||||
[selfserve_lane_relay::MACHINE, false],
|
||||
[selfserve_lane_relay::MACHINE_CLEANER, false],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use objects\subuser_grants_o;
|
||||
|
||||
it('keeps default subuser grant permissions JSON encodable', function (): void {
|
||||
$encoded = json_encode(subuser_grants_o::defaultPermissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
expect($encoded)->toBeString();
|
||||
expect(json_decode($encoded, true))
|
||||
->toContain('VEHICLES_LIST')
|
||||
->toContain('SELFSERVE_ADD')
|
||||
->toContain('BOOKINGS_LIST');
|
||||
});
|
||||
|
||||
it('normalizes stored subuser grant permission payloads', function (): void {
|
||||
expect(subuser_grants_o::normalizePermissionsValue(0))->toBe([]);
|
||||
expect(subuser_grants_o::normalizePermissionsValue('0'))->toBe([]);
|
||||
expect(subuser_grants_o::normalizePermissionsValue('["vehicles_list","BOOKINGS_ADD"]'))
|
||||
->toBe(['VEHICLES_LIST', 'BOOKINGS_ADD']);
|
||||
expect(subuser_grants_o::normalizePermissionsValue([
|
||||
'VEHICLES_LIST' => true,
|
||||
'BOOKINGS_DELETE' => false,
|
||||
'UNKNOWN_PERMISSION' => true,
|
||||
]))->toBe(['VEHICLES_LIST']);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use objects\subusers_o;
|
||||
|
||||
it('accepts subuser passwords that match the shared policy', function (): void {
|
||||
subusers_o::assertValidPassword('Test1234');
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects subuser passwords that do not match the shared policy', function (string $password): void {
|
||||
expect(fn () => subusers_o::assertValidPassword($password))
|
||||
->toThrow(Exception::class, 'Password must be between 8 and 255 characters long');
|
||||
})->with([
|
||||
'too short' => ['Tes123'],
|
||||
'missing uppercase' => ['test1234'],
|
||||
'missing lowercase' => ['TEST1234'],
|
||||
'missing number' => ['TestPassword'],
|
||||
'too long' => [str_repeat('A', 256) . 'a1'],
|
||||
]);
|
||||
@@ -9,6 +9,9 @@ it('exposes chauffeur management endpoints on the subusers route', function ():
|
||||
|
||||
expect($normalized)->toContain("\$this->post('/subusers/invite', function () {");
|
||||
expect($normalized)->toContain("\$this->post('/subusers/invite/resend', function () {");
|
||||
expect($normalized)->toContain("\$this->get('/superuser/subusers', function () {");
|
||||
expect($normalized)->toContain("\$this->post('/superuser/subusers/invite', function () {");
|
||||
expect($normalized)->toContain("\$this->post('/superuser/subusers/invite/resend', function () {");
|
||||
expect($normalized)->toContain("\$this->put('/subusers', function () {");
|
||||
expect($normalized)->toContain("\$this->put('/subusers/me', function () {");
|
||||
});
|
||||
@@ -31,6 +34,20 @@ it('includes grant management fields in the subusers payload builder', function
|
||||
expect($normalized)->toContain("'access_state' =>");
|
||||
});
|
||||
|
||||
it('resolves subuser customer names without external lookups during list requests', function (): void {
|
||||
$routeFile = app_path('routes/subusersRoute.php');
|
||||
expect(is_file($routeFile))->toBeTrue();
|
||||
|
||||
$code = (string)file_get_contents($routeFile);
|
||||
$normalized = preg_replace('/\s+/', ' ', $code);
|
||||
|
||||
expect($normalized)->toContain('getCustomerNames($customerNumbers, false)');
|
||||
expect($normalized)->toContain('$customerName = $this->resolveCustomerName($customerNumber);');
|
||||
expect($normalized)->toContain('buildSubuserManagementPayload($subuser, $customerNumber, $customerName)');
|
||||
expect($normalized)->not->toContain("'customer_name' => (new users_o())->getCustomerName(\$customerNumber)");
|
||||
expect($normalized)->not->toContain("'name' => (new users_o())->getCustomerName((int)\$grant['billing_customer_number'])");
|
||||
});
|
||||
|
||||
it('links grant disable operations to SUBUSERS_DELETE for own-customer managers', function (): void {
|
||||
$routeFile = app_path('routes/subusersRoute.php');
|
||||
expect(is_file($routeFile))->toBeTrue();
|
||||
|
||||
@@ -77,7 +77,7 @@ class _TestLane extends selfserve_lane {
|
||||
protected function hasMachineStartSignalForStop(): bool { return true; }
|
||||
protected function completeLatestSessionForStop(): void {}
|
||||
public function open(selfserve_lane_port $port, ?int $toggle_after_seconds = null): bool { return true; }
|
||||
public function invoice(): bool { return true; }
|
||||
public function invoice(?selfserve_lane_command_arguments $arguments = null): bool { return true; }
|
||||
public function logLaneAction(\modules\selfserve\helpers\selfserve_lane_log_action $action, int $status_code = 200, array $extra_data = []): void { /* no-op */ }
|
||||
public function setRelayStatusHard(selfserve_lane_relay $relay, bool $on): bool { if ($relay === selfserve_lane_relay::MACHINE && $on === false) { $this->relayOffCalled = true; } return true; }
|
||||
public function turnOffRelay(selfserve_lane_relay $relay): bool { $this->relayOffCalled = true; return true; }
|
||||
|
||||
@@ -28,7 +28,7 @@ http:
|
||||
- main: api.truckwash.dk
|
||||
|
||||
api-preflight-io:
|
||||
rule: (Host(`api.truckwash.io`) || Host(`localhost`)) && Method(`OPTIONS`)
|
||||
rule: (Host(`api.truckwash.io`) || Host(`api-v2.truckwash.io`) || Host(`localhost`)) && Method(`OPTIONS`)
|
||||
entryPoints: [websecure, websecure-staging]
|
||||
middlewares: [secure-headers]
|
||||
service: noop@internal
|
||||
@@ -37,6 +37,7 @@ http:
|
||||
certResolver: le_io
|
||||
domains:
|
||||
- main: api.truckwash.io
|
||||
- main: api-v2.truckwash.io
|
||||
|
||||
cloud-preflight:
|
||||
rule: Host(`cloud.truckwash.dk`) && Method(`OPTIONS`)
|
||||
@@ -82,6 +83,7 @@ http:
|
||||
- "https://www.truckwash.io"
|
||||
- "https://api.truckwash.io"
|
||||
- "https://api.truckwash.io:4433"
|
||||
- "https://api-v2.truckwash.io"
|
||||
- "https://web.truckwash.dk"
|
||||
- "https://api.truckwash.dk"
|
||||
- "https://truckwash.dk"
|
||||
@@ -107,6 +109,8 @@ http:
|
||||
- X-Release-Trace
|
||||
- X-Release-Channel
|
||||
- X-Frontend-Version
|
||||
- Cache-Control
|
||||
- Pragma
|
||||
api-ratelimit:
|
||||
rateLimit:
|
||||
average: 100
|
||||
|
||||
Reference in New Issue
Block a user