Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4dd49208a |
@@ -9,7 +9,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
assign-task:
|
assign-task:
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -164,30 +164,7 @@ jobs:
|
|||||||
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
|
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
|
||||||
|
|
||||||
- name: Resolve dependencies
|
- name: Resolve dependencies
|
||||||
run: |
|
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress"
|
||||||
set -euo pipefail
|
|
||||||
composer_install() {
|
|
||||||
install_mode="$1"
|
|
||||||
max_attempts="$2"
|
|
||||||
attempt=1
|
|
||||||
while :; do
|
|
||||||
if docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer install --no-interaction ${install_mode} --no-progress"; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
sleep_seconds=$((attempt * 5))
|
|
||||||
echo "composer install ${install_mode} failed; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/${max_attempts})" >&2
|
|
||||||
sleep "$sleep_seconds"
|
|
||||||
attempt=$((attempt + 1))
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
composer_install --prefer-dist 3 || {
|
|
||||||
echo "Composer dist install failed; retrying with --prefer-source." >&2
|
|
||||||
composer_install --prefer-source 2
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Verify edge gateway test files
|
- name: Verify edge gateway test files
|
||||||
run: >
|
run: >
|
||||||
@@ -302,7 +279,7 @@ jobs:
|
|||||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-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\":[\"api_gateway\"]}")"
|
--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\":[]}")"
|
||||||
response_body="$(cat "$response_file")"
|
response_body="$(cat "$response_file")"
|
||||||
rm -f "$response_file"
|
rm -f "$response_file"
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ COPY . /var/www/html
|
|||||||
|
|
||||||
# Copy Nginx configuration file
|
# Copy Nginx configuration file
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
|
||||||
|
|
||||||
# Install Composer
|
# Install Composer
|
||||||
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ RUN set -eux; \
|
|||||||
|
|
||||||
COPY services/nginx/app/ /var/www/html/
|
COPY services/nginx/app/ /var/www/html/
|
||||||
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
|
||||||
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
|
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
|
||||||
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
|
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -3,8 +3,6 @@ services:
|
|||||||
traefik:
|
traefik:
|
||||||
image: traefik:2.11
|
image: traefik:2.11
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
group_add:
|
|
||||||
- "${DOCKER_SOCKET_GID:-65534}"
|
|
||||||
ports:
|
ports:
|
||||||
- "${TRAEFIK_WEB_PORT:-80}:80"
|
- "${TRAEFIK_WEB_PORT:-80}:80"
|
||||||
- "${TRAEFIK_WEBSECURE_PORT:-443}:443"
|
- "${TRAEFIK_WEBSECURE_PORT:-443}:443"
|
||||||
@@ -104,7 +102,6 @@ services:
|
|||||||
image: mysql:8.4
|
image: mysql:8.4
|
||||||
container_name: mysql-debug
|
container_name: mysql-debug
|
||||||
profiles: [dev]
|
profiles: [dev]
|
||||||
command: ["mysqld", "--innodb-use-native-aio=0"]
|
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:?CONFIG_DB_DEBUG_PASSWORD is required for mysql-debug}
|
MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:?CONFIG_DB_DEBUG_PASSWORD is required for mysql-debug}
|
||||||
MYSQL_DATABASE: ${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}
|
MYSQL_DATABASE: ${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}
|
||||||
|
|||||||
@@ -3357,9 +3357,6 @@
|
|||||||
},
|
},
|
||||||
"email_notifications_enabled": {
|
"email_notifications_enabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
|
||||||
"superuser_new_customer_email_notifications_enabled": {
|
|
||||||
"type": "boolean"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12958,54 +12955,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/slack/config": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Config"
|
|
||||||
],
|
|
||||||
"summary": "Get Slack config",
|
|
||||||
"operationId": "getSlackConfig",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Slack configuration retrieved successfully",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/SlackConfigListResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"tags": [
|
|
||||||
"Config"
|
|
||||||
],
|
|
||||||
"summary": "Update Slack config",
|
|
||||||
"operationId": "updateSlackConfig",
|
|
||||||
"requestBody": {
|
|
||||||
"required": false,
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Slack configuration updated successfully",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ModuleConfigUpdateResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/backups/config": {
|
"/backups/config": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -15547,39 +15496,6 @@
|
|||||||
"value"
|
"value"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"SlackConfigEntry": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"module": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"Slack"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"variable": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"customer_registration_webhook_url"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"string"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"value": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "https://hooks.slack.com/services/..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"module",
|
|
||||||
"variable",
|
|
||||||
"type",
|
|
||||||
"value"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"BackupsConfigEntry": {
|
"BackupsConfigEntry": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -16324,27 +16240,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"SlackConfigListResponse": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/ModuleConfigEnvelopeBase"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"data": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/SlackConfigEntry"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"data"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"BackupsConfigListResponse": {
|
"BackupsConfigListResponse": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
|
|||||||
+199
-2921
File diff suppressed because it is too large
Load Diff
-132545
File diff suppressed because one or more lines are too long
+3
-27
@@ -68,22 +68,6 @@ retry_command() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
composer_install() {
|
|
||||||
dist_attempts="${PHP_CI_COMPOSER_RETRIES:-3}"
|
|
||||||
source_attempts="${PHP_CI_COMPOSER_SOURCE_RETRIES:-2}"
|
|
||||||
|
|
||||||
if retry_command "$dist_attempts" \
|
|
||||||
docker compose $compose_files exec -T php1 sh -lc \
|
|
||||||
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Composer dist install failed after ${dist_attempts} attempts; retrying with --prefer-source." >&2
|
|
||||||
retry_command "$source_attempts" \
|
|
||||||
docker compose $compose_files exec -T php1 sh -lc \
|
|
||||||
'cd /var/www/html && composer install --no-interaction --prefer-source --no-progress'
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
status="$?"
|
status="$?"
|
||||||
collect_logs "$status"
|
collect_logs "$status"
|
||||||
@@ -128,16 +112,8 @@ tar \
|
|||||||
-C services/nginx/app -cf - . \
|
-C services/nginx/app -cf - . \
|
||||||
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
|
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
|
||||||
|
|
||||||
docker compose $compose_files exec -T php1 sh -lc 'rm -rf /var/www/repo-root && mkdir -p /var/www/repo-root'
|
docker compose $compose_files exec -T php1 sh -lc \
|
||||||
tar \
|
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'
|
||||||
-cf - \
|
|
||||||
Dockerfile \
|
|
||||||
Dockerfile.coolify-api \
|
|
||||||
services/php/Dockerfile \
|
|
||||||
services/php/php-fpm-pool.conf \
|
|
||||||
| docker compose $compose_files exec -T php1 tar -C /var/www/repo-root -xf -
|
|
||||||
|
|
||||||
composer_install
|
|
||||||
|
|
||||||
docker compose $compose_files exec -T php1 sh -lc \
|
docker compose $compose_files exec -T php1 sh -lc \
|
||||||
"cd /var/www/html && PLENO_REPO_ROOT_FOR_TESTS=/var/www/repo-root composer test:ci:$suite"
|
"cd /var/www/html && composer test:ci:$suite"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -12204,251 +12204,3 @@
|
|||||||
[Tue May 26 09:31:07 2026] 127.0.0.1:38284 Closing
|
[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:08 2026] 127.0.0.1:38290 Accepted
|
||||||
[Tue May 26 09:31:12 2026] 127.0.0.1:38290 Closing
|
[Tue May 26 09:31:12 2026] 127.0.0.1:38290 Closing
|
||||||
[Tue Jun 2 12:55:52 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45377) started
|
|
||||||
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52720 Accepted
|
|
||||||
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52720 Closing
|
|
||||||
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52734 Accepted
|
|
||||||
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52734 Closing
|
|
||||||
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52742 Accepted
|
|
||||||
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52742 Closing
|
|
||||||
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52750 Accepted
|
|
||||||
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52750 Closing
|
|
||||||
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52766 Accepted
|
|
||||||
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52766 Closing
|
|
||||||
[Tue Jun 2 12:55:53 2026] 127.0.0.1:52770 Accepted
|
|
||||||
[Tue Jun 2 12:55:53 2026] 127.0.0.1:52770 Closing
|
|
||||||
[Tue Jun 2 12:55:53 2026] 127.0.0.1:52784 Accepted
|
|
||||||
[Tue Jun 2 12:55:53 2026] 127.0.0.1:52784 Closing
|
|
||||||
[Tue Jun 2 12:55:53 2026] 127.0.0.1:52792 Accepted
|
|
||||||
[Tue Jun 2 12:55:53 2026] 127.0.0.1:52792 Closing
|
|
||||||
[Tue Jun 2 12:55:53 2026] 127.0.0.1:52802 Accepted
|
|
||||||
[Tue Jun 2 12:55:53 2026] 127.0.0.1:52802 Closing
|
|
||||||
[Tue Jun 2 12:59:15 2026] PHP 8.2.15 Development Server (http://127.0.0.1:42651) started
|
|
||||||
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41826 Accepted
|
|
||||||
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41826 Closing
|
|
||||||
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41842 Accepted
|
|
||||||
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41842 Closing
|
|
||||||
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41850 Accepted
|
|
||||||
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41850 Closing
|
|
||||||
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41860 Accepted
|
|
||||||
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41860 Closing
|
|
||||||
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41874 Accepted
|
|
||||||
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41874 Closing
|
|
||||||
[Tue Jun 2 12:59:16 2026] 127.0.0.1:41884 Accepted
|
|
||||||
[Tue Jun 2 12:59:16 2026] 127.0.0.1:41884 Closing
|
|
||||||
[Tue Jun 2 12:59:16 2026] 127.0.0.1:41894 Accepted
|
|
||||||
[Tue Jun 2 12:59:16 2026] 127.0.0.1:41894 Closing
|
|
||||||
[Tue Jun 2 12:59:16 2026] 127.0.0.1:41902 Accepted
|
|
||||||
[Tue Jun 2 12:59:16 2026] 127.0.0.1:41902 Closing
|
|
||||||
[Tue Jun 2 12:59:16 2026] 127.0.0.1:41916 Accepted
|
|
||||||
[Tue Jun 2 12:59:37 2026] 127.0.0.1:41916 Closing
|
|
||||||
[Tue Jun 2 13:02:13 2026] PHP 8.2.15 Development Server (http://127.0.0.1:38777) started
|
|
||||||
[Tue Jun 2 13:02:13 2026] 127.0.0.1:37238 Accepted
|
|
||||||
[Tue Jun 2 13:02:13 2026] 127.0.0.1:37238 Closing
|
|
||||||
[Tue Jun 2 13:02:13 2026] 127.0.0.1:37252 Accepted
|
|
||||||
[Tue Jun 2 13:02:13 2026] 127.0.0.1:37252 Closing
|
|
||||||
[Tue Jun 2 13:02:13 2026] 127.0.0.1:37256 Accepted
|
|
||||||
[Tue Jun 2 13:02:13 2026] 127.0.0.1:37256 Closing
|
|
||||||
[Tue Jun 2 13:02:15 2026] 127.0.0.1:35516 Accepted
|
|
||||||
[Tue Jun 2 13:02:15 2026] 127.0.0.1:35516 Closing
|
|
||||||
[Tue Jun 2 13:02:15 2026] 127.0.0.1:35518 Accepted
|
|
||||||
[Tue Jun 2 13:02:15 2026] 127.0.0.1:35518 Closing
|
|
||||||
[Tue Jun 2 13:02:15 2026] 127.0.0.1:35528 Accepted
|
|
||||||
[Tue Jun 2 13:02:15 2026] 127.0.0.1:35528 Closing
|
|
||||||
[Tue Jun 2 13:02:16 2026] 127.0.0.1:35544 Accepted
|
|
||||||
[Tue Jun 2 13:02:16 2026] 127.0.0.1:35544 Closing
|
|
||||||
[Tue Jun 2 13:02:16 2026] 127.0.0.1:35552 Accepted
|
|
||||||
[Tue Jun 2 13:02:16 2026] 127.0.0.1:35552 Closing
|
|
||||||
[Tue Jun 2 13:02:16 2026] 127.0.0.1:35568 Accepted
|
|
||||||
[Tue Jun 2 13:02:16 2026] 127.0.0.1:35568 Closing
|
|
||||||
[Tue Jun 2 13:02:16 2026] 127.0.0.1:35582 Accepted
|
|
||||||
[Tue Jun 2 13:02:16 2026] 127.0.0.1:35582 Closing
|
|
||||||
[Tue Jun 2 13:02:16 2026] 127.0.0.1:35586 Accepted
|
|
||||||
[Tue Jun 2 13:02:17 2026] 127.0.0.1:35586 Closing
|
|
||||||
[Tue Jun 2 13:02:17 2026] 127.0.0.1:35588 Accepted
|
|
||||||
[Tue Jun 2 13:02:17 2026] 127.0.0.1:35588 Closing
|
|
||||||
[Tue Jun 2 13:02:19 2026] 127.0.0.1:35596 Accepted
|
|
||||||
[Tue Jun 2 13:02:20 2026] 127.0.0.1:35596 Closing
|
|
||||||
[Tue Jun 2 13:02:20 2026] 127.0.0.1:35600 Accepted
|
|
||||||
[Tue Jun 2 13:02:20 2026] 127.0.0.1:35600 Closing
|
|
||||||
[Tue Jun 2 13:02:20 2026] 127.0.0.1:35612 Accepted
|
|
||||||
[Tue Jun 2 13:02:20 2026] 127.0.0.1:35612 Closing
|
|
||||||
[Tue Jun 2 13:02:46 2026] PHP 8.2.15 Development Server (http://127.0.0.1:39573) started
|
|
||||||
[Tue Jun 2 13:02:46 2026] 127.0.0.1:43488 Accepted
|
|
||||||
[Tue Jun 2 13:02:46 2026] 127.0.0.1:43488 Closing
|
|
||||||
[Tue Jun 2 13:02:46 2026] 127.0.0.1:43494 Accepted
|
|
||||||
[Tue Jun 2 13:02:47 2026] 127.0.0.1:43494 Closing
|
|
||||||
[Tue Jun 2 13:02:47 2026] 127.0.0.1:43502 Accepted
|
|
||||||
[Tue Jun 2 13:02:47 2026] 127.0.0.1:43502 Closing
|
|
||||||
[Tue Jun 2 13:02:47 2026] 127.0.0.1:43504 Accepted
|
|
||||||
[Tue Jun 2 13:02:47 2026] 127.0.0.1:43504 Closing
|
|
||||||
[Tue Jun 2 13:02:47 2026] 127.0.0.1:43516 Accepted
|
|
||||||
[Tue Jun 2 13:02:47 2026] 127.0.0.1:43516 Closing
|
|
||||||
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43530 Accepted
|
|
||||||
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43530 Closing
|
|
||||||
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43540 Accepted
|
|
||||||
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43540 Closing
|
|
||||||
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43550 Accepted
|
|
||||||
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43550 Closing
|
|
||||||
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43562 Accepted
|
|
||||||
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43562 Closing
|
|
||||||
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43578 Accepted
|
|
||||||
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43578 Closing
|
|
||||||
[Tue Jun 2 13:02:54 2026] 127.0.0.1:51950 Accepted
|
|
||||||
[Tue Jun 2 13:02:54 2026] 127.0.0.1:51950 Closing
|
|
||||||
[Tue Jun 2 13:02:54 2026] 127.0.0.1:51952 Accepted
|
|
||||||
[Tue Jun 2 13:02:54 2026] 127.0.0.1:51952 Closing
|
|
||||||
[Tue Jun 2 13:02:54 2026] 127.0.0.1:51964 Accepted
|
|
||||||
[Tue Jun 2 13:02:54 2026] 127.0.0.1:51964 Closing
|
|
||||||
[Tue Jun 2 13:02:54 2026] 127.0.0.1:51978 Accepted
|
|
||||||
[Tue Jun 2 13:02:54 2026] 127.0.0.1:51978 Closing
|
|
||||||
[Tue Jun 2 13:02:55 2026] 127.0.0.1:51994 Accepted
|
|
||||||
[Tue Jun 2 13:02:55 2026] 127.0.0.1:51994 Closing
|
|
||||||
[Tue Jun 2 13:25:35 2026] PHP 8.2.15 Development Server (http://127.0.0.1:40811) started
|
|
||||||
[Tue Jun 2 13:25:35 2026] 127.0.0.1:43736 Accepted
|
|
||||||
[Tue Jun 2 13:25:35 2026] 127.0.0.1:43736 Closing
|
|
||||||
[Tue Jun 2 13:25:35 2026] 127.0.0.1:43740 Accepted
|
|
||||||
[Tue Jun 2 13:25:35 2026] 127.0.0.1:43740 Closing
|
|
||||||
[Tue Jun 2 13:25:35 2026] 127.0.0.1:43754 Accepted
|
|
||||||
[Tue Jun 2 13:25:35 2026] 127.0.0.1:43754 Closing
|
|
||||||
[Tue Jun 2 13:25:36 2026] 127.0.0.1:43768 Accepted
|
|
||||||
[Tue Jun 2 13:25:36 2026] 127.0.0.1:43768 Closing
|
|
||||||
[Tue Jun 2 13:25:36 2026] 127.0.0.1:43776 Accepted
|
|
||||||
[Tue Jun 2 13:25:36 2026] 127.0.0.1:43776 Closing
|
|
||||||
[Tue Jun 2 13:25:52 2026] 127.0.0.1:57442 Accepted
|
|
||||||
[Tue Jun 2 13:25:52 2026] 127.0.0.1:57442 Closing
|
|
||||||
[Tue Jun 2 13:25:52 2026] 127.0.0.1:57452 Accepted
|
|
||||||
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57452 Closing
|
|
||||||
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57460 Accepted
|
|
||||||
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57460 Closing
|
|
||||||
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57472 Accepted
|
|
||||||
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57472 Closing
|
|
||||||
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57486 Accepted
|
|
||||||
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57486 Closing
|
|
||||||
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57492 Accepted
|
|
||||||
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57492 Closing
|
|
||||||
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57508 Accepted
|
|
||||||
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57508 Closing
|
|
||||||
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57522 Accepted
|
|
||||||
[Tue Jun 2 13:25:54 2026] 127.0.0.1:57522 Closing
|
|
||||||
[Tue Jun 2 13:25:54 2026] 127.0.0.1:60940 Accepted
|
|
||||||
[Tue Jun 2 13:25:54 2026] 127.0.0.1:60940 Closing
|
|
||||||
[Tue Jun 2 13:25:54 2026] 127.0.0.1:60950 Accepted
|
|
||||||
[Tue Jun 2 13:25:54 2026] 127.0.0.1:60950 Closing
|
|
||||||
[Tue Jun 2 13:44:42 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37125) started
|
|
||||||
[Tue Jun 2 13:44:42 2026] 127.0.0.1:45382 Accepted
|
|
||||||
[Tue Jun 2 13:44:42 2026] 127.0.0.1:45382 Closing
|
|
||||||
[Tue Jun 2 13:44:42 2026] 127.0.0.1:45388 Accepted
|
|
||||||
[Tue Jun 2 13:44:42 2026] 127.0.0.1:45388 Closing
|
|
||||||
[Tue Jun 2 13:44:42 2026] 127.0.0.1:45398 Accepted
|
|
||||||
[Tue Jun 2 13:44:43 2026] 127.0.0.1:45398 Closing
|
|
||||||
[Tue Jun 2 13:44:43 2026] 127.0.0.1:45410 Accepted
|
|
||||||
[Tue Jun 2 13:44:46 2026] 127.0.0.1:45410 Closing
|
|
||||||
[Tue Jun 2 13:44:46 2026] 127.0.0.1:53928 Accepted
|
|
||||||
[Tue Jun 2 13:44:46 2026] 127.0.0.1:53928 Closing
|
|
||||||
[Tue Jun 2 13:44:51 2026] PHP 8.2.15 Development Server (http://127.0.0.1:34643) started
|
|
||||||
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46776 Accepted
|
|
||||||
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46776 Closing
|
|
||||||
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46782 Accepted
|
|
||||||
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46782 Closing
|
|
||||||
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46796 Accepted
|
|
||||||
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46796 Closing
|
|
||||||
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46806 Accepted
|
|
||||||
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46806 Closing
|
|
||||||
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46816 Accepted
|
|
||||||
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46816 Closing
|
|
||||||
[Tue Jun 2 13:44:52 2026] 127.0.0.1:46824 Accepted
|
|
||||||
[Tue Jun 2 13:44:52 2026] 127.0.0.1:46824 Closing
|
|
||||||
[Tue Jun 2 13:44:53 2026] 127.0.0.1:46836 Accepted
|
|
||||||
[Tue Jun 2 13:44:53 2026] 127.0.0.1:46836 Closing
|
|
||||||
[Tue Jun 2 13:44:53 2026] 127.0.0.1:46846 Accepted
|
|
||||||
[Tue Jun 2 13:44:53 2026] 127.0.0.1:46846 Closing
|
|
||||||
[Tue Jun 2 13:44:53 2026] 127.0.0.1:46848 Accepted
|
|
||||||
[Tue Jun 2 13:44:53 2026] 127.0.0.1:46848 Closing
|
|
||||||
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45254 Accepted
|
|
||||||
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45254 Closing
|
|
||||||
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45264 Accepted
|
|
||||||
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45264 Closing
|
|
||||||
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45276 Accepted
|
|
||||||
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45276 Closing
|
|
||||||
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45282 Accepted
|
|
||||||
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45282 Closing
|
|
||||||
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45288 Accepted
|
|
||||||
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45288 Closing
|
|
||||||
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45296 Accepted
|
|
||||||
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45296 Closing
|
|
||||||
[Tue Jun 2 13:44:55 2026] 127.0.0.1:45312 Accepted
|
|
||||||
[Tue Jun 2 13:44:55 2026] 127.0.0.1:45312 Closing
|
|
||||||
[Tue Jun 2 13:44:55 2026] 127.0.0.1:45314 Accepted
|
|
||||||
[Tue Jun 2 13:44:55 2026] 127.0.0.1:45314 Closing
|
|
||||||
[Tue Jun 2 13:44:55 2026] 127.0.0.1:45328 Accepted
|
|
||||||
[Tue Jun 2 13:44:55 2026] 127.0.0.1:45328 Closing
|
|
||||||
[Tue Jun 2 13:44:55 2026] 127.0.0.1:45330 Accepted
|
|
||||||
[Tue Jun 2 13:44:55 2026] 127.0.0.1:45330 Closing
|
|
||||||
[Tue Jun 2 14:14:22 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41283) started
|
|
||||||
[Tue Jun 2 14:14:22 2026] 127.0.0.1:54804 Accepted
|
|
||||||
[Tue Jun 2 14:14:22 2026] 127.0.0.1:54804 Closing
|
|
||||||
[Tue Jun 2 14:14:22 2026] 127.0.0.1:54812 Accepted
|
|
||||||
[Tue Jun 2 14:14:22 2026] 127.0.0.1:54812 Closing
|
|
||||||
[Tue Jun 2 14:14:22 2026] 127.0.0.1:54816 Accepted
|
|
||||||
[Tue Jun 2 14:14:22 2026] 127.0.0.1:54816 Closing
|
|
||||||
[Tue Jun 2 14:14:22 2026] 127.0.0.1:54826 Accepted
|
|
||||||
[Tue Jun 2 14:14:23 2026] 127.0.0.1:54826 Closing
|
|
||||||
[Tue Jun 2 14:14:23 2026] 127.0.0.1:54828 Accepted
|
|
||||||
[Tue Jun 2 14:14:25 2026] 127.0.0.1:54828 Closing
|
|
||||||
[Tue Jun 2 14:14:25 2026] 127.0.0.1:45644 Accepted
|
|
||||||
[Tue Jun 2 14:14:25 2026] 127.0.0.1:45644 Closing
|
|
||||||
[Tue Jun 2 14:14:42 2026] PHP 8.2.15 Development Server (http://127.0.0.1:40157) started
|
|
||||||
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46410 Accepted
|
|
||||||
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46410 Closing
|
|
||||||
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46414 Accepted
|
|
||||||
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46414 Closing
|
|
||||||
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46424 Accepted
|
|
||||||
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46424 Closing
|
|
||||||
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46440 Accepted
|
|
||||||
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46440 Closing
|
|
||||||
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46442 Accepted
|
|
||||||
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46442 Closing
|
|
||||||
[Tue Jun 2 14:14:43 2026] 127.0.0.1:46458 Accepted
|
|
||||||
[Tue Jun 2 14:14:43 2026] 127.0.0.1:46458 Closing
|
|
||||||
[Tue Jun 2 14:16:10 2026] PHP 8.2.15 Development Server (http://127.0.0.1:38915) started
|
|
||||||
[Tue Jun 2 14:16:10 2026] 127.0.0.1:54470 Accepted
|
|
||||||
[Tue Jun 2 14:16:10 2026] 127.0.0.1:54470 Closing
|
|
||||||
[Tue Jun 2 14:16:10 2026] 127.0.0.1:54482 Accepted
|
|
||||||
[Tue Jun 2 14:16:11 2026] 127.0.0.1:54482 Closing
|
|
||||||
[Tue Jun 2 14:16:11 2026] 127.0.0.1:54492 Accepted
|
|
||||||
[Tue Jun 2 14:16:11 2026] 127.0.0.1:54492 Closing
|
|
||||||
[Tue Jun 2 14:16:11 2026] 127.0.0.1:54498 Accepted
|
|
||||||
[Tue Jun 2 14:16:11 2026] 127.0.0.1:54498 Closing
|
|
||||||
[Tue Jun 2 14:16:11 2026] 127.0.0.1:54508 Accepted
|
|
||||||
[Tue Jun 2 14:16:11 2026] 127.0.0.1:54508 Closing
|
|
||||||
[Tue Jun 2 14:16:24 2026] 127.0.0.1:48044 Accepted
|
|
||||||
[Tue Jun 2 14:16:24 2026] 127.0.0.1:48044 Closing
|
|
||||||
[Tue Jun 2 14:16:25 2026] 127.0.0.1:48060 Accepted
|
|
||||||
[Tue Jun 2 14:16:25 2026] 127.0.0.1:48060 Closing
|
|
||||||
[Tue Jun 2 14:16:27 2026] 127.0.0.1:48072 Accepted
|
|
||||||
[Tue Jun 2 14:16:27 2026] 127.0.0.1:48072 Closing
|
|
||||||
[Tue Jun 2 14:16:27 2026] 127.0.0.1:48088 Accepted
|
|
||||||
[Tue Jun 2 14:16:28 2026] 127.0.0.1:48088 Closing
|
|
||||||
[Tue Jun 2 14:16:28 2026] 127.0.0.1:48094 Accepted
|
|
||||||
[Tue Jun 2 14:16:28 2026] 127.0.0.1:48094 Closing
|
|
||||||
[Tue Jun 2 14:16:34 2026] 127.0.0.1:55000 Accepted
|
|
||||||
[Tue Jun 2 14:16:34 2026] 127.0.0.1:55000 Closing
|
|
||||||
[Tue Jun 2 14:16:34 2026] 127.0.0.1:55006 Accepted
|
|
||||||
[Tue Jun 2 14:16:34 2026] 127.0.0.1:55006 Closing
|
|
||||||
[Tue Jun 2 14:16:34 2026] 127.0.0.1:55016 Accepted
|
|
||||||
[Tue Jun 2 14:16:34 2026] 127.0.0.1:55016 Closing
|
|
||||||
[Tue Jun 2 14:16:34 2026] 127.0.0.1:55018 Accepted
|
|
||||||
[Tue Jun 2 14:16:34 2026] 127.0.0.1:55018 Closing
|
|
||||||
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55022 Accepted
|
|
||||||
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55022 Closing
|
|
||||||
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55032 Accepted
|
|
||||||
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55032 Closing
|
|
||||||
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55048 Accepted
|
|
||||||
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55048 Closing
|
|
||||||
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55060 Accepted
|
|
||||||
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55060 Closing
|
|
||||||
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55074 Accepted
|
|
||||||
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55074 Closing
|
|
||||||
[Tue Jun 2 14:17:06 2026] 127.0.0.1:34490 Accepted
|
|
||||||
[Tue Jun 2 14:17:06 2026] 127.0.0.1:34490 Closing
|
|
||||||
|
|||||||
@@ -133,14 +133,8 @@ class attachments implements attachments_i
|
|||||||
protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array
|
protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array
|
||||||
{
|
{
|
||||||
$options = $this->normalizeAttachmentOptions($options);
|
$options = $this->normalizeAttachmentOptions($options);
|
||||||
$rawType = trim($type, '`');
|
|
||||||
$objectTypes = array_values(array_unique([
|
|
||||||
$rawType,
|
|
||||||
'`' . $rawType . '`',
|
|
||||||
]));
|
|
||||||
|
|
||||||
return (new object_attachments_o())->getFieldsWhereIn([
|
return (new object_attachments_o())->getFieldsWhereIn([
|
||||||
'object_type' => $objectTypes,
|
'object_type' => $type,
|
||||||
'object_id' => $object_ids,
|
'object_id' => $object_ids,
|
||||||
'deleted_at' => null
|
'deleted_at' => null
|
||||||
], $options);
|
], $options);
|
||||||
|
|||||||
@@ -121,16 +121,6 @@ class coolify_api_client
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function listApplicationEnvs(string $uuid): array
|
|
||||||
{
|
|
||||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/envs');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteApplicationEnv(string $uuid, string $envUuid): array
|
|
||||||
{
|
|
||||||
return $this->request('DELETE', '/applications/' . rawurlencode($uuid) . '/envs/' . rawurlencode($envUuid));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function bulkEnvData(array $env): array
|
private static function bulkEnvData(array $env): array
|
||||||
{
|
{
|
||||||
$data = [];
|
$data = [];
|
||||||
@@ -169,11 +159,6 @@ class coolify_api_client
|
|||||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
|
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function stopApplication(string $uuid): array
|
|
||||||
{
|
|
||||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/stop');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteService(string $uuid): array
|
public function deleteService(string $uuid): array
|
||||||
{
|
{
|
||||||
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
|
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class coolify_manager
|
|||||||
'targets' => $this->listTargets(),
|
'targets' => $this->listTargets(),
|
||||||
'availability' => $this->availabilitySummary(),
|
'availability' => $this->availabilitySummary(),
|
||||||
'load_balancer' => $this->loadBalancerSummary(),
|
'load_balancer' => $this->loadBalancerSummary(),
|
||||||
|
'github_runners' => $this->githubRunnerSummary(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1371,6 +1372,54 @@ class coolify_manager
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function githubRunnerSummary(): array
|
||||||
|
{
|
||||||
|
$this->ensureSchema();
|
||||||
|
$repositories = $this->githubRunnerRepositories([]);
|
||||||
|
$labels = $this->githubRunnerLabels(null);
|
||||||
|
$countPerRepo = $this->githubRunnerCount(null);
|
||||||
|
$serviceUuid = $this->nullableString($this->coolifyConfigValue('github_runner_service_uuid', ''));
|
||||||
|
$token = $this->githubRunnerToken([]);
|
||||||
|
$config = [
|
||||||
|
'configured' => $serviceUuid !== null,
|
||||||
|
'service_uuid' => $serviceUuid,
|
||||||
|
'service_name' => $this->githubRunnerServiceName(null),
|
||||||
|
'repositories' => $repositories,
|
||||||
|
'labels' => $labels,
|
||||||
|
'count_per_repo' => $countPerRepo,
|
||||||
|
'desired_runner_count' => count($repositories) * $countPerRepo,
|
||||||
|
'compose_hash' => $this->composeHash($this->githubRunnerComposeTemplate($repositories, $labels, $countPerRepo)),
|
||||||
|
'token_set' => $token !== '',
|
||||||
|
'token_source' => trim((string)(getenv('GITHUB_RUNNER_TOKEN') ?: getenv('GITHUB_TOKEN') ?: '')) !== '' ? 'env' : ($token !== '' ? 'config' : null),
|
||||||
|
];
|
||||||
|
|
||||||
|
$base = [
|
||||||
|
'status' => $serviceUuid === null ? 'not_configured' : 'configured',
|
||||||
|
'config' => $config,
|
||||||
|
'service' => null,
|
||||||
|
'last_error' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($serviceUuid === null || !coolify_schema_bootstrap::tablesExist()) {
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$instanceId = $this->defaultInstanceId();
|
||||||
|
$instance = $this->getInstance($instanceId);
|
||||||
|
$service = $this->clientForInstance($instance)->getService($serviceUuid);
|
||||||
|
return array_replace($base, [
|
||||||
|
'status' => 'ok',
|
||||||
|
'service' => self::redactCoolifyResponse($service),
|
||||||
|
]);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
return array_replace($base, [
|
||||||
|
'status' => 'unknown',
|
||||||
|
'last_error' => $throwable->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function deployGithubRunners(array $input, ?int $actorUserId = null): array
|
public function deployGithubRunners(array $input, ?int $actorUserId = null): array
|
||||||
{
|
{
|
||||||
$this->ensureSchema();
|
$this->ensureSchema();
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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_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 ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
|
||||||
public const EXPOSED_HEADERS = 'Server-Timing';
|
|
||||||
public const MAX_AGE_SECONDS = '86400';
|
public const MAX_AGE_SECONDS = '86400';
|
||||||
|
|
||||||
private const REQUIRED_ALLOWED_ORIGINS = [
|
private const REQUIRED_ALLOWED_ORIGINS = [
|
||||||
@@ -26,9 +25,6 @@ class cors_policy
|
|||||||
'https://localhost:4433',
|
'https://localhost:4433',
|
||||||
'https://twdev.jeppeb.dk',
|
'https://twdev.jeppeb.dk',
|
||||||
'http://localhost:5173',
|
'http://localhost:5173',
|
||||||
'http://localhost:5174',
|
|
||||||
'http://127.0.0.1:5173',
|
|
||||||
'http://127.0.0.1:5174',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public static function normalizeOrigin(?string $value): string
|
public static function normalizeOrigin(?string $value): string
|
||||||
@@ -129,9 +125,7 @@ class cors_policy
|
|||||||
'Access-Control-Allow-Credentials' => 'true',
|
'Access-Control-Allow-Credentials' => 'true',
|
||||||
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
|
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
|
||||||
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
|
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
|
||||||
'Access-Control-Expose-Headers' => self::EXPOSED_HEADERS,
|
|
||||||
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
|
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
|
||||||
'Timing-Allow-Origin' => $origin,
|
|
||||||
'Vary' => 'Origin',
|
'Vary' => 'Origin',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,13 +128,13 @@ use Psr\Http\Client\ClientExceptionInterface;
|
|||||||
private function sendEmailMailerSend(string $to, string $recipient_name, string $subject, string $message, string $html = null, string $references = null, array $attachments = []): void
|
private function sendEmailMailerSend(string $to, string $recipient_name, string $subject, string $message, string $html = null, string $references = null, array $attachments = []): void
|
||||||
{
|
{
|
||||||
if (self::isFakeDeliveryEnabled()) {
|
if (self::isFakeDeliveryEnabled()) {
|
||||||
self::recordFakeDelivery([
|
self::$fake_deliveries[] = [
|
||||||
'to' => $to,
|
'to' => $to,
|
||||||
'recipient_name' => $recipient_name,
|
'recipient_name' => $recipient_name,
|
||||||
'subject' => $subject,
|
'subject' => $subject,
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
'html' => $html,
|
'html' => $html,
|
||||||
]);
|
];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,72 +225,6 @@ use Psr\Http\Client\ClientExceptionInterface;
|
|||||||
public static function resetFakeDeliveries(): void
|
public static function resetFakeDeliveries(): void
|
||||||
{
|
{
|
||||||
self::$fake_deliveries = [];
|
self::$fake_deliveries = [];
|
||||||
$path = self::getFakeDeliveriesPath();
|
|
||||||
if ($path !== null && is_file($path)) {
|
|
||||||
unlink($path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function syncFakeDeliveries(): void
|
|
||||||
{
|
|
||||||
$path = self::getFakeDeliveriesPath();
|
|
||||||
if ($path === null || !is_file($path)) {
|
|
||||||
self::$fake_deliveries = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
||||||
if ($lines === false) {
|
|
||||||
self::$fake_deliveries = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$deliveries = [];
|
|
||||||
foreach ($lines as $line) {
|
|
||||||
$delivery = json_decode($line, true);
|
|
||||||
if (is_array($delivery)) {
|
|
||||||
$deliveries[] = $delivery;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self::$fake_deliveries = $deliveries;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function recordFakeDelivery(array $delivery): void
|
|
||||||
{
|
|
||||||
self::$fake_deliveries[] = $delivery;
|
|
||||||
|
|
||||||
$path = self::getFakeDeliveriesPath();
|
|
||||||
if ($path === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$directory = dirname($path);
|
|
||||||
if (!is_dir($directory)) {
|
|
||||||
mkdir($directory, 0777, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
file_put_contents($path, json_encode($delivery, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND | LOCK_EX);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function getFakeDeliveriesPath(): ?string
|
|
||||||
{
|
|
||||||
if (!self::isFakeDeliveryEnabled()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$configuredPath = trim((string)(getenv('EMAIL_FAKE_DELIVERIES_PATH') ?: ''));
|
|
||||||
if ($configuredPath !== '') {
|
|
||||||
return $configuredPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (getenv('RUN_API_TESTS') !== '1') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
|
|
||||||
. DIRECTORY_SEPARATOR
|
|
||||||
. 'truckwash-email-fake-deliveries-' . md5((string)getcwd()) . '.jsonl';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function isFakeDeliveryEnabled(): bool
|
private static function isFakeDeliveryEnabled(): bool
|
||||||
@@ -577,47 +511,4 @@ use Psr\Http\Client\ClientExceptionInterface;
|
|||||||
$this->attachments
|
$this->attachments
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
public function sendNewCustomerRegistrationNotifications(int $customer_number): void
|
|
||||||
{
|
|
||||||
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
|
|
||||||
if (!$customer->exists()) {
|
|
||||||
throw new Exception('Customer not found with customer number: ' . $customer_number);
|
|
||||||
}
|
|
||||||
|
|
||||||
$customerName = $customer->getCustomerName((int)$customer->customer_number->value()) ?: 'Unknown customer';
|
|
||||||
$safeCustomerName = htmlspecialchars($customerName, ENT_QUOTES, 'UTF-8');
|
|
||||||
$safeCustomerNumber = (int)$customer->customer_number->value();
|
|
||||||
$customerUrl = 'https://truckwash.io/superuser/users?search=' . $safeCustomerNumber;
|
|
||||||
$message = "
|
|
||||||
<p>A new customer has registered on truckwash.io.</p>
|
|
||||||
<p>
|
|
||||||
<strong>Customer number:</strong> $safeCustomerNumber<br>
|
|
||||||
<strong>Customer name:</strong> $safeCustomerName
|
|
||||||
</p>
|
|
||||||
<p><a href='$customerUrl'>Open customer in Superuser</a></p>
|
|
||||||
";
|
|
||||||
|
|
||||||
foreach ((new users_o())->getSuperuserNewCustomerEmailNotificationRecipients() as $recipient) {
|
|
||||||
$recipientEmail = trim((string)($recipient['email'] ?? ''));
|
|
||||||
if ($recipientEmail === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$recipientName = trim((string)($recipient['display_name'] ?? ''));
|
|
||||||
if ($recipientName === '') {
|
|
||||||
$recipientName = $recipientEmail;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->sendEmail(
|
|
||||||
$recipientEmail,
|
|
||||||
$recipientName,
|
|
||||||
'New customer registered on Truck Wash',
|
|
||||||
$message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,22 +10,6 @@ use licenseplaterecognizer\licenseplaterecognizer_c;
|
|||||||
|
|
||||||
class licenseplaterecognizer implements licenseplaterecognizer_i
|
class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||||
{
|
{
|
||||||
private const DEFAULT_API_URL = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk';
|
|
||||||
private const PLATE_READER_CONFIG_JSON = '{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0}';
|
|
||||||
private const RESULT_CACHE_CONTEXT = '{"config":{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0},"regions":"dk,de,se,no"}';
|
|
||||||
private const PLATE_READER_REGIONS = 'dk,de,se,no';
|
|
||||||
private const DEFAULT_UPLOAD_FILE_NAME = 'license-plate.jpg';
|
|
||||||
private const RUNTIME_CONFIG_CACHE_TTL_SECONDS = 15;
|
|
||||||
private const RUNTIME_CONFIG_REDIS_CACHE_KEY = 'licenseplaterecognizer:runtime_config:v1';
|
|
||||||
private const RESULT_CACHE_TTL_SECONDS = 10;
|
|
||||||
private const RESULT_CACHE_REDIS_KEY_PREFIX = 'licenseplaterecognizer:result:v1:';
|
|
||||||
private const PLATE_READER_CONNECT_TIMEOUT_MS = 1000;
|
|
||||||
private const PLATE_READER_TOTAL_TIMEOUT_MS = 4500;
|
|
||||||
/**
|
|
||||||
* @var array<string, float>
|
|
||||||
*/
|
|
||||||
private array $last_timings = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The configuration of the module
|
* The configuration of the module
|
||||||
* @var licenseplaterecognizer_c
|
* @var licenseplaterecognizer_c
|
||||||
@@ -35,25 +19,12 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
|||||||
* API URL
|
* API URL
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
private string $api_url;
|
private string $api_url = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk'; // Default (cloud): 'https://api.platerecognizer.com'; (without /v1/plate-reader/)';
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array{enabled: bool, api_key: string}|null
|
|
||||||
*/
|
|
||||||
private ?array $runtime_config = null;
|
|
||||||
|
|
||||||
/**
|
public function __construct()
|
||||||
* @var array{values: array{enabled: bool, api_key: string}, cached_at: float}|null
|
|
||||||
*/
|
|
||||||
private static ?array $runtime_config_cache = null;
|
|
||||||
|
|
||||||
public function __construct(bool $load_config = true, ?string $api_url = null)
|
|
||||||
{
|
{
|
||||||
$this->api_url = self::normalizeApiUrl($api_url ?? self::configuredApiUrl());
|
$this->config = new licenseplaterecognizer_c();
|
||||||
|
|
||||||
if ($load_config) {
|
|
||||||
$this->config = new licenseplaterecognizer_c();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -62,7 +33,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
|||||||
*/
|
*/
|
||||||
public function requireModuleEnabled(): void
|
public function requireModuleEnabled(): void
|
||||||
{
|
{
|
||||||
if (!$this->runtimeConfig()['enabled']) {
|
if (!(bool)$this->config->enabled->getVariableValue()) {
|
||||||
throw new Exception('licenseplaterecognizer module is not enabled.');
|
throw new Exception('licenseplaterecognizer module is not enabled.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,516 +45,54 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
|||||||
*/
|
*/
|
||||||
public function licenseplaterecognizer(string $base64_image): array
|
public function licenseplaterecognizer(string $base64_image): array
|
||||||
{
|
{
|
||||||
return $this->recognizePlate(
|
$image_processor = new image_processor();
|
||||||
fn () => $this->buildPlateReaderPayload($base64_image),
|
|
||||||
fn () => $this->buildResultCacheKeyFromUploadString($base64_image)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array
|
//ADD PARAMETER IN REQUEST LIKE regions
|
||||||
{
|
$data = array(
|
||||||
return $this->recognizePlate(
|
'upload' => $base64_image,
|
||||||
fn () => $this->buildPlateReaderPayloadFromUpload(
|
//'regions' => 'dk' // Optional
|
||||||
$this->buildUploadValueFromBytes($image_data, $mime_type)
|
|
||||||
),
|
|
||||||
fn () => $this->buildResultCacheKeyFromBytes($image_data)
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array
|
// Prepare new cURL resource
|
||||||
{
|
//$ch = curl_init('https://api.platerecognizer.com/v1/plate-reader/');
|
||||||
return $this->recognizePlate(
|
$ch = curl_init($this->api_url . '/v1/plate-reader/');
|
||||||
fn () => $this->buildPlateReaderPayloadFromUpload(
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
$this->buildUploadValueFromBytes($image_data, $mime_type)
|
curl_setopt($ch, CURLINFO_HEADER_OUT, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2TLS);
|
||||||
|
|
||||||
|
// Set HTTP Header for POST request
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
|
||||||
|
"Authorization: Token " . $this->config->api_key->getVariableValue()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array
|
// Submit the POST request and close cURL session handle
|
||||||
{
|
$result = curl_exec($ch);
|
||||||
return $this->recognizePlate(
|
curl_close($ch);
|
||||||
fn () => $this->buildPlateReaderPayloadFromUpload(
|
// Print the response from the server
|
||||||
$this->buildUploadValueFromFile($image_path, $mime_type)
|
if ($result === false) {
|
||||||
)
|
throw new Exception('Error in API request.');
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
$response_data = json_decode($result, true);
|
||||||
* @throws Exception
|
if (isset($response_data['results']) && count($response_data['results']) > 0) {
|
||||||
*/
|
return [
|
||||||
private function recognizePlate(callable $payload_factory, ?callable $result_cache_key_factory = null): array
|
'success' => true,
|
||||||
{
|
'license_plate_number' => $response_data['results'][0]['plate'] ?? null,
|
||||||
$started_at = microtime(true);
|
'confidence' => $response_data['results'][0]['score'] ?? null,
|
||||||
$this->last_timings = [];
|
'raw_response' => $response_data,
|
||||||
$result_cache = null;
|
|
||||||
$result_cache_key = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$config_started_at = microtime(true);
|
|
||||||
$runtime_config = $this->runtimeConfig();
|
|
||||||
if (!$runtime_config['enabled']) {
|
|
||||||
throw new Exception('licenseplaterecognizer module is not enabled.');
|
|
||||||
}
|
|
||||||
$api_key = $runtime_config['api_key'];
|
|
||||||
$this->last_timings['config'] = $this->elapsedMs($config_started_at);
|
|
||||||
|
|
||||||
if ($result_cache_key_factory !== null) {
|
|
||||||
$cache_started_at = microtime(true);
|
|
||||||
try {
|
|
||||||
$result_cache = $this->resultCacheStore();
|
|
||||||
if ($result_cache !== null) {
|
|
||||||
$result_cache_key = $result_cache_key_factory();
|
|
||||||
if ($result_cache_key !== null) {
|
|
||||||
$cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key);
|
|
||||||
if ($cached_result !== null) {
|
|
||||||
$this->last_timings['cache_hit'] = 1;
|
|
||||||
return $cached_result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->last_timings['cache_miss'] = 1;
|
|
||||||
} finally {
|
|
||||||
$this->last_timings['cache'] = $this->elapsedMs($cache_started_at);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$payload_started_at = microtime(true);
|
|
||||||
$data = $payload_factory();
|
|
||||||
$this->last_timings['payload'] = $this->elapsedMs($payload_started_at);
|
|
||||||
|
|
||||||
$ch = curl_init($this->api_url . '/v1/plate-reader/');
|
|
||||||
if (!$ch instanceof \CurlHandle) {
|
|
||||||
throw new Exception('Error initializing API request.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$curl_options = [
|
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
|
||||||
CURLOPT_POST => true,
|
|
||||||
CURLOPT_POSTFIELDS => $data,
|
|
||||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
|
|
||||||
CURLOPT_CONNECTTIMEOUT_MS => self::PLATE_READER_CONNECT_TIMEOUT_MS,
|
|
||||||
CURLOPT_TIMEOUT_MS => self::PLATE_READER_TOTAL_TIMEOUT_MS,
|
|
||||||
CURLOPT_NOSIGNAL => true,
|
|
||||||
CURLOPT_NOPROGRESS => false,
|
|
||||||
CURLOPT_XFERINFOFUNCTION => self::clientDisconnectAbortCallback(),
|
|
||||||
CURLOPT_HTTPHEADER => [
|
|
||||||
"Authorization: Token " . $api_key,
|
|
||||||
'Expect:',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
if (defined('CURLOPT_TCP_NODELAY')) {
|
} else {
|
||||||
$curl_options[(int)constant('CURLOPT_TCP_NODELAY')] = true;
|
return [
|
||||||
}
|
|
||||||
curl_setopt_array($ch, $curl_options);
|
|
||||||
|
|
||||||
// Submit the POST request and close cURL session handle
|
|
||||||
$upstream_started_at = microtime(true);
|
|
||||||
$result = curl_exec($ch);
|
|
||||||
$this->last_timings['upstream'] = $this->elapsedMs($upstream_started_at);
|
|
||||||
$this->recordCurlTimings($ch);
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
// Print the response from the server
|
|
||||||
if ($result === false) {
|
|
||||||
throw new Exception('Error in API request.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$parse_started_at = microtime(true);
|
|
||||||
$response_data = json_decode($result, true);
|
|
||||||
$this->last_timings['parse'] = $this->elapsedMs($parse_started_at);
|
|
||||||
$this->recordResponseTimings($response_data);
|
|
||||||
|
|
||||||
if (isset($response_data['results']) && count($response_data['results']) > 0) {
|
|
||||||
$recognized_result = [
|
|
||||||
'success' => true,
|
|
||||||
'license_plate_number' => $response_data['results'][0]['plate'] ?? null,
|
|
||||||
'confidence' => $response_data['results'][0]['score'] ?? null,
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
|
|
||||||
|
|
||||||
return $recognized_result;
|
|
||||||
}
|
|
||||||
|
|
||||||
$recognized_result = [
|
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'No license plate detected.',
|
'message' => 'No license plate detected.',
|
||||||
|
'raw_response' => $response_data,
|
||||||
];
|
];
|
||||||
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
|
|
||||||
|
|
||||||
return $recognized_result;
|
|
||||||
} finally {
|
|
||||||
$this->last_timings['total'] = $this->elapsedMs($started_at);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function clientDisconnectAbortCallback(): callable
|
|
||||||
{
|
|
||||||
return static function (): int {
|
|
||||||
return connection_aborted() ? 1 : 0;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLastTimings(): array
|
|
||||||
{
|
|
||||||
return $this->last_timings;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function elapsedMs(float $started_at): float
|
|
||||||
{
|
|
||||||
return (microtime(true) - $started_at) * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function configuredApiUrl(): string
|
|
||||||
{
|
|
||||||
$configured = getenv('PLATE_RECOGNIZER_API_URL');
|
|
||||||
if ($configured === false || trim((string)$configured) === '') {
|
|
||||||
$configured = $_ENV['PLATE_RECOGNIZER_API_URL'] ?? $_SERVER['PLATE_RECOGNIZER_API_URL'] ?? self::DEFAULT_API_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (string)$configured;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function normalizeApiUrl(string $api_url): string
|
|
||||||
{
|
|
||||||
$api_url = trim($api_url);
|
|
||||||
if ($api_url === '') {
|
|
||||||
return self::DEFAULT_API_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rtrim($api_url, '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function recordCurlTimings(\CurlHandle $curl_handle): void
|
|
||||||
{
|
|
||||||
$mapping = [
|
|
||||||
CURLINFO_NAMELOOKUP_TIME => 'upstream_dns',
|
|
||||||
CURLINFO_CONNECT_TIME => 'upstream_connect',
|
|
||||||
CURLINFO_APPCONNECT_TIME => 'upstream_tls',
|
|
||||||
CURLINFO_PRETRANSFER_TIME => 'upstream_pretransfer',
|
|
||||||
CURLINFO_STARTTRANSFER_TIME => 'upstream_ttfb',
|
|
||||||
CURLINFO_TOTAL_TIME => 'upstream_total',
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($mapping as $curl_info_option => $timing_key) {
|
|
||||||
$value = curl_getinfo($curl_handle, $curl_info_option);
|
|
||||||
if (!is_numeric($value)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->last_timings[$timing_key] = max(0, (float)$value * 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function recordResponseTimings(mixed $response_data): void
|
|
||||||
{
|
|
||||||
if (!is_array($response_data) || !isset($response_data['processing_time']) || !is_numeric($response_data['processing_time'])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->last_timings['upstream_processing'] = max(0, (float)$response_data['processing_time']);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildResultCacheKeyFromUploadString(string $base64_image): string
|
|
||||||
{
|
|
||||||
$base64_image = trim($base64_image);
|
|
||||||
if (preg_match('/^data:image\/[a-zA-Z0-9.+-]+;base64,(.*)$/s', $base64_image, $matches) === 1) {
|
|
||||||
$image_data = base64_decode((string)$matches[1], true);
|
|
||||||
if (is_string($image_data)) {
|
|
||||||
return $this->buildResultCacheKeyFromBytes($image_data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->buildResultCacheKeyFromBytes($base64_image);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildResultCacheKeyFromBytes(string $image_data): string
|
|
||||||
{
|
|
||||||
$context = hash_init('sha256');
|
|
||||||
hash_update($context, $this->resultCacheContext());
|
|
||||||
hash_update($context, "\0");
|
|
||||||
hash_update($context, $image_data);
|
|
||||||
|
|
||||||
return self::RESULT_CACHE_REDIS_KEY_PREFIX . hash_final($context);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resultCacheContext(): string
|
|
||||||
{
|
|
||||||
return self::RESULT_CACHE_CONTEXT;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function resultCacheStore(): ?object
|
|
||||||
{
|
|
||||||
return $this->runtimeConfigCacheStore();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function readRecognitionResultCache(?object $cache, ?string $key): ?array
|
|
||||||
{
|
|
||||||
if ($cache === null || $key === null || !method_exists($cache, 'get')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$cached = $cache->get($key);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!is_string($cached) || trim($cached) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$decoded = json_decode($cached, true);
|
|
||||||
if (!is_array($decoded) || !array_key_exists('success', $decoded)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $decoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function writeRecognitionResultCache(?object $cache, ?string $key, array $result): void
|
|
||||||
{
|
|
||||||
if ($cache === null || $key === null || !method_exists($cache, 'setEx')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$encoded = json_encode($result, JSON_UNESCAPED_SLASHES);
|
|
||||||
if (is_string($encoded)) {
|
|
||||||
$cache->setEx($key, $encoded, self::RESULT_CACHE_TTL_SECONDS);
|
|
||||||
}
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// Scanner result cache is best-effort; Plate Recognizer remains the source of truth.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function buildPlateReaderPayload(string $base64_image): array
|
|
||||||
{
|
|
||||||
return $this->buildPlateReaderPayloadFromUpload($this->buildUploadValue($base64_image));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function buildPlateReaderPayloadFromUpload(string|\CURLFile|\CURLStringFile $upload): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'upload' => $upload,
|
|
||||||
'config' => self::PLATE_READER_CONFIG_JSON,
|
|
||||||
'regions' => self::PLATE_READER_REGIONS,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildUploadValue(string $base64_image): string|\CURLStringFile
|
|
||||||
{
|
|
||||||
$base64_image = trim($base64_image);
|
|
||||||
if (preg_match('/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.*)$/s', $base64_image, $matches) !== 1) {
|
|
||||||
return $base64_image;
|
|
||||||
}
|
|
||||||
|
|
||||||
$image_data = base64_decode((string)$matches[2], true);
|
|
||||||
if ($image_data === false || !class_exists(\CURLStringFile::class)) {
|
|
||||||
return (string)$matches[2];
|
|
||||||
}
|
|
||||||
|
|
||||||
return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, (string)$matches[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildUploadValueFromBytes(string $image_data, string $mime_type): string|\CURLStringFile
|
|
||||||
{
|
|
||||||
$mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg';
|
|
||||||
if (!str_starts_with($mime_type, 'image/')) {
|
|
||||||
$mime_type = 'image/jpeg';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!class_exists(\CURLStringFile::class)) {
|
|
||||||
return $image_data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, $mime_type);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
private function buildUploadValueFromFile(string $image_path, string $mime_type): \CURLFile
|
|
||||||
{
|
|
||||||
$image_path = trim($image_path);
|
|
||||||
$mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg';
|
|
||||||
if (!str_starts_with($mime_type, 'image/')) {
|
|
||||||
$mime_type = 'image/jpeg';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($image_path === '' || !is_file($image_path) || !class_exists(\CURLFile::class)) {
|
|
||||||
throw new Exception('Image upload file is invalid.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new \CURLFile($image_path, $mime_type, self::DEFAULT_UPLOAD_FILE_NAME);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function runtimeConfig(): array
|
|
||||||
{
|
|
||||||
if ($this->runtime_config !== null) {
|
|
||||||
return $this->runtime_config;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->shouldUseSharedRuntimeConfigCache()) {
|
|
||||||
$cached_config = self::getSharedRuntimeConfigCache();
|
|
||||||
if ($cached_config !== null) {
|
|
||||||
$this->runtime_config = $cached_config;
|
|
||||||
|
|
||||||
return $this->runtime_config;
|
|
||||||
}
|
|
||||||
|
|
||||||
$cached_config = $this->readRuntimeConfigCacheStore();
|
|
||||||
if ($cached_config !== null) {
|
|
||||||
self::$runtime_config_cache = [
|
|
||||||
'values' => $cached_config,
|
|
||||||
'cached_at' => microtime(true),
|
|
||||||
];
|
|
||||||
$this->runtime_config = $cached_config;
|
|
||||||
|
|
||||||
return $this->runtime_config;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$values = $this->readRuntimeModuleConfig();
|
|
||||||
|
|
||||||
$this->runtime_config = [
|
|
||||||
'enabled' => $this->parseModuleConfigBool($values['enabled'] ?? false),
|
|
||||||
'api_key' => (string)($values['api_key'] ?? ''),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($this->shouldUseSharedRuntimeConfigCache()) {
|
|
||||||
self::$runtime_config_cache = [
|
|
||||||
'values' => $this->runtime_config,
|
|
||||||
'cached_at' => microtime(true),
|
|
||||||
];
|
|
||||||
$this->writeRuntimeConfigCacheStore($this->runtime_config);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->runtime_config;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function shouldUseSharedRuntimeConfigCache(): bool
|
|
||||||
{
|
|
||||||
return static::class === self::class;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function getSharedRuntimeConfigCache(): ?array
|
|
||||||
{
|
|
||||||
if (self::$runtime_config_cache === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$cache_age_seconds = microtime(true) - self::$runtime_config_cache['cached_at'];
|
|
||||||
if ($cache_age_seconds > self::RUNTIME_CONFIG_CACHE_TTL_SECONDS) {
|
|
||||||
self::$runtime_config_cache = null;
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::$runtime_config_cache['values'];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function runtimeConfigCacheStore(): ?object
|
|
||||||
{
|
|
||||||
return defined('redis') ? constant('redis') : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function readRuntimeConfigCacheStore(): ?array
|
|
||||||
{
|
|
||||||
$cache = $this->runtimeConfigCacheStore();
|
|
||||||
if ($cache === null || !method_exists($cache, 'get')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$cached = $cache->get(self::RUNTIME_CONFIG_REDIS_CACHE_KEY);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!is_string($cached) || trim($cached) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$decoded = json_decode($cached, true);
|
|
||||||
if (!is_array($decoded)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!array_key_exists('enabled', $decoded) || !array_key_exists('api_key', $decoded)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'enabled' => $this->parseModuleConfigBool($decoded['enabled']),
|
|
||||||
'api_key' => (string)$decoded['api_key'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{enabled: bool, api_key: string} $config
|
|
||||||
*/
|
|
||||||
private function writeRuntimeConfigCacheStore(array $config): void
|
|
||||||
{
|
|
||||||
$cache = $this->runtimeConfigCacheStore();
|
|
||||||
if ($cache === null || !method_exists($cache, 'setEx')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$encoded = json_encode($config, JSON_UNESCAPED_SLASHES);
|
|
||||||
if (is_string($encoded)) {
|
|
||||||
$cache->setEx(self::RUNTIME_CONFIG_REDIS_CACHE_KEY, $encoded, self::RUNTIME_CONFIG_CACHE_TTL_SECONDS);
|
|
||||||
}
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// Scanner config cache is best-effort; DB remains the source of truth.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function parseModuleConfigBool(mixed $value): bool
|
|
||||||
{
|
|
||||||
if (is_bool($value)) {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_numeric($value)) {
|
|
||||||
return (int)$value === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return strtolower(trim((string)$value)) === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function readRuntimeModuleConfig(): array
|
|
||||||
{
|
|
||||||
global $db;
|
|
||||||
|
|
||||||
if ($db instanceof db) {
|
|
||||||
$module = $db->escape_string('licenseplaterecognizer');
|
|
||||||
$result = $db->query("SELECT variable, value FROM module_config WHERE module = '$module' AND variable IN ('enabled', 'api_key')");
|
|
||||||
$values = [];
|
|
||||||
|
|
||||||
if ($result instanceof \mysqli_result) {
|
|
||||||
while ($row = $result->fetch_assoc()) {
|
|
||||||
$variable = (string)($row['variable'] ?? '');
|
|
||||||
if ($variable !== '') {
|
|
||||||
$values[$variable] = (string)($row['value'] ?? '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $values;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($this->config)) {
|
|
||||||
$this->config = new licenseplaterecognizer_c();
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'enabled' => (string)$this->config->enabled->getVariableValue(),
|
|
||||||
'api_key' => (string)$this->config->api_key->getVariableValue(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritDoc
|
* @inheritDoc
|
||||||
* @throws Exception If the module is not enabled or if there is an error in the API request
|
* @throws Exception If the module is not enabled or if there is an error in the API request
|
||||||
@@ -591,8 +100,8 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
|||||||
*/
|
*/
|
||||||
public function get_usage(): licenseplaterecognizer_info
|
public function get_usage(): licenseplaterecognizer_info
|
||||||
{
|
{
|
||||||
|
// Require the module to be enabled
|
||||||
$this->requireModuleEnabled();
|
$this->requireModuleEnabled();
|
||||||
$api_key = $this->runtimeConfig()['api_key'];
|
|
||||||
$curl = curl_init();
|
$curl = curl_init();
|
||||||
curl_setopt_array($curl, array(
|
curl_setopt_array($curl, array(
|
||||||
CURLOPT_URL => $this->api_url . '/info/',
|
CURLOPT_URL => $this->api_url . '/info/',
|
||||||
@@ -603,9 +112,9 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
|||||||
CURLOPT_FOLLOWLOCATION => true,
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
|
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
|
||||||
CURLOPT_CUSTOMREQUEST => 'GET',
|
CURLOPT_CUSTOMREQUEST => 'GET',
|
||||||
CURLOPT_HTTPHEADER => [
|
CURLOPT_HTTPHEADER => array(
|
||||||
'Authorization: Token ' . $api_key,
|
'Authorization: Token ' . $this->config->api_key->getVariableValue()
|
||||||
],
|
),
|
||||||
));
|
));
|
||||||
$response = curl_exec($curl);
|
$response = curl_exec($curl);
|
||||||
curl_close($curl);
|
curl_close($curl);
|
||||||
@@ -615,4 +124,4 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
|||||||
}
|
}
|
||||||
return new licenseplaterecognizer_info($response_data);
|
return new licenseplaterecognizer_info($response_data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,12 +47,8 @@ class pdf_store implements minio_pdfs_i
|
|||||||
*/
|
*/
|
||||||
public function download(string $file): string
|
public function download(string $file): string
|
||||||
{
|
{
|
||||||
if ($this->shouldUseLocalTestStorage()) {
|
|
||||||
return $this->getLocalTestObjectPath($file);
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = '/tmp/' . $file;
|
$path = '/tmp/' . $file;
|
||||||
self::getS3Client()->getObject([
|
$result = self::getS3Client()->getObject([
|
||||||
'Bucket' => self::getBucket(),
|
'Bucket' => self::getBucket(),
|
||||||
'Key' => $file,
|
'Key' => $file,
|
||||||
'SaveAs' => $path
|
'SaveAs' => $path
|
||||||
|
|||||||
@@ -1434,8 +1434,6 @@ class release_manager
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$expectedCommit = self::normalizeCommitSha((string)($gateInput['expected_commit'] ?? ''));
|
|
||||||
$enforceExpectedCommit = $expectedCommit !== '' && !$this->releaseGateAutoSyncRequested($gateInput);
|
|
||||||
$checked = [];
|
$checked = [];
|
||||||
try {
|
try {
|
||||||
foreach ($gateInput['api_ping_paths'] as $path) {
|
foreach ($gateInput['api_ping_paths'] as $path) {
|
||||||
@@ -1444,19 +1442,9 @@ class release_manager
|
|||||||
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
|
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
|
||||||
throw new RuntimeException(sprintf('%s returned success=false.', $path));
|
throw new RuntimeException(sprintf('%s returned success=false.', $path));
|
||||||
}
|
}
|
||||||
$actualCommit = $this->releaseGateApiPayloadCommitSha($payload);
|
|
||||||
if ($enforceExpectedCommit && !$this->releaseGateCommitMatches($actualCommit, $expectedCommit)) {
|
|
||||||
throw new RuntimeException(sprintf(
|
|
||||||
'%s returned commit %s, expected %s.',
|
|
||||||
$path,
|
|
||||||
$actualCommit !== '' ? $actualCommit : 'unknown',
|
|
||||||
$expectedCommit
|
|
||||||
));
|
|
||||||
}
|
|
||||||
$checked[] = [
|
$checked[] = [
|
||||||
'path' => $path,
|
'path' => $path,
|
||||||
'status' => $json['status'],
|
'status' => $json['status'],
|
||||||
'commit_sha' => $actualCommit !== '' ? $actualCommit : null,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
} catch (Throwable $throwable) {
|
} catch (Throwable $throwable) {
|
||||||
@@ -1483,24 +1471,10 @@ class release_manager
|
|||||||
'context' => [
|
'context' => [
|
||||||
'api_base_url' => $apiBaseUrl,
|
'api_base_url' => $apiBaseUrl,
|
||||||
'checked' => $checked,
|
'checked' => $checked,
|
||||||
'expected_commit' => $expectedCommit !== '' ? $expectedCommit : null,
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function releaseGateApiPayloadCommitSha(array $payload): string
|
|
||||||
{
|
|
||||||
$data = is_array($payload['data'] ?? null) ? $payload['data'] : $payload;
|
|
||||||
foreach (['api_commit_sha', 'backend_version', 'commit_sha', 'version'] as $key) {
|
|
||||||
$commit = self::normalizeCommitSha((string)($data[$key] ?? ''));
|
|
||||||
if ($commit !== '') {
|
|
||||||
return $commit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function releaseGateFetchJson(string $baseUrl, string $path): array
|
private function releaseGateFetchJson(string $baseUrl, string $path): array
|
||||||
{
|
{
|
||||||
$result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $path));
|
$result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $path));
|
||||||
@@ -2092,7 +2066,11 @@ class release_manager
|
|||||||
|
|
||||||
$eventId = (int)$event['id'];
|
$eventId = (int)$event['id'];
|
||||||
if (!$this->acquireReleaseAutoSyncLock($eventId)) {
|
if (!$this->acquireReleaseAutoSyncLock($eventId)) {
|
||||||
return $this->waitForReleaseAutoSyncEventResult($eventId, $channelId, $app, $commitSha, $gateInput);
|
return [
|
||||||
|
'step_status' => 'passed',
|
||||||
|
'message' => 'Automatic container update is already being processed for this commit.',
|
||||||
|
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2174,67 +2152,6 @@ class release_manager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function waitForReleaseAutoSyncEventResult(int $eventId, int $channelId, string $app, string $commitSha, array $gateInput): array
|
|
||||||
{
|
|
||||||
$timeout = max(0, min(300, (int)($gateInput['wait_timeout_seconds'] ?? 300)));
|
|
||||||
$pollInterval = max(1, min(60, (int)($gateInput['poll_interval_seconds'] ?? 10)));
|
|
||||||
$deadline = time() + $timeout;
|
|
||||||
$attempts = 0;
|
|
||||||
$lastStatus = 'unknown';
|
|
||||||
|
|
||||||
do {
|
|
||||||
$attempts++;
|
|
||||||
$event = $this->releaseAutoSyncEventById($eventId);
|
|
||||||
if ($event === null) {
|
|
||||||
throw new RuntimeException('Automatic container update disappeared while another request was processing it.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$lastStatus = (string)($event['status'] ?? 'unknown');
|
|
||||||
if (in_array($lastStatus, ['promoted', 'deployed'], true)) {
|
|
||||||
$deployment = null;
|
|
||||||
$deploymentId = $this->nullablePositiveInt($event['deployment_id'] ?? null);
|
|
||||||
if ($deploymentId !== null) {
|
|
||||||
$deployment = $this->getDeployment($deploymentId);
|
|
||||||
}
|
|
||||||
$deployment ??= $this->currentDeploymentForChannelApp($channelId, $app);
|
|
||||||
if ($deployment !== null && $this->releaseGateCommitMatches((string)($deployment['commit_sha'] ?? ''), $commitSha)) {
|
|
||||||
return [
|
|
||||||
'step_status' => 'passed',
|
|
||||||
'message' => sprintf('%s container update completed by an in-flight request at %s.', strtoupper($app), substr($commitSha, 0, 12)),
|
|
||||||
'deployment' => $this->publicDeployment($deployment),
|
|
||||||
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
|
|
||||||
'attempts' => $attempts,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new RuntimeException(sprintf(
|
|
||||||
'Automatic container update completed for event %d but the active %s deployment does not match %s.',
|
|
||||||
$eventId,
|
|
||||||
strtoupper($app),
|
|
||||||
$commitSha
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($lastStatus === 'failed') {
|
|
||||||
$message = trim((string)($event['error_message'] ?? 'Automatic container update failed in another request.'));
|
|
||||||
throw new RuntimeException($message !== '' ? $message : 'Automatic container update failed in another request.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (time() >= $deadline) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
sleep($pollInterval);
|
|
||||||
} while (true);
|
|
||||||
|
|
||||||
throw new RuntimeException(sprintf(
|
|
||||||
'Automatic container update is already being processed for event %d but did not finish within %d seconds; last status was %s.',
|
|
||||||
$eventId,
|
|
||||||
$timeout,
|
|
||||||
$lastStatus
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function upsertReleaseAutoSyncEvent(array $input): array
|
private function upsertReleaseAutoSyncEvent(array $input): array
|
||||||
{
|
{
|
||||||
$channelId = (int)$input['channel_id'];
|
$channelId = (int)$input['channel_id'];
|
||||||
@@ -6315,7 +6232,6 @@ class release_manager
|
|||||||
}
|
}
|
||||||
|
|
||||||
$deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context));
|
$deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context));
|
||||||
$previousApplications = $this->stopCoolifyPreviousApplications($client, $context, $serviceUuid);
|
|
||||||
return [
|
return [
|
||||||
'service_uuid' => $serviceUuid,
|
'service_uuid' => $serviceUuid,
|
||||||
'resource_type' => $resourceType,
|
'resource_type' => $resourceType,
|
||||||
@@ -6325,76 +6241,9 @@ class release_manager
|
|||||||
'updated' => self::redactPayload($update ?? []),
|
'updated' => self::redactPayload($update ?? []),
|
||||||
'runtime_env' => $runtimeEnvUpdate,
|
'runtime_env' => $runtimeEnvUpdate,
|
||||||
'deployment' => self::redactPayload($deployment),
|
'deployment' => self::redactPayload($deployment),
|
||||||
'previous_applications' => self::redactPayload($previousApplications),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function stopCoolifyPreviousApplications(coolify_api_client $client, array $context, string $activeUuid): array
|
|
||||||
{
|
|
||||||
$stopped = [];
|
|
||||||
foreach ($this->releaseCoolifyPreviousApplicationUuids($context, $activeUuid) as $uuid) {
|
|
||||||
try {
|
|
||||||
$stopped[] = [
|
|
||||||
'uuid' => $uuid,
|
|
||||||
'status' => 'stop_requested',
|
|
||||||
'result' => $client->stopApplication($uuid),
|
|
||||||
];
|
|
||||||
} catch (Throwable $throwable) {
|
|
||||||
$stopped[] = [
|
|
||||||
'uuid' => $uuid,
|
|
||||||
'status' => 'warning',
|
|
||||||
'error' => $throwable->getMessage(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $stopped;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function releaseCoolifyPreviousApplicationUuids(array $context, string $activeUuid): array
|
|
||||||
{
|
|
||||||
$values = [];
|
|
||||||
foreach ([
|
|
||||||
'coolify_previous_application_uuid',
|
|
||||||
'coolify_previous_artifact_app_uuid',
|
|
||||||
'coolify_previous_artifact_application_uuid',
|
|
||||||
'previous_application_uuid',
|
|
||||||
'previous_app_uuid',
|
|
||||||
] as $key) {
|
|
||||||
if (is_scalar($context[$key] ?? null)) {
|
|
||||||
$values[] = (string)$context[$key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ([
|
|
||||||
'coolify_previous_application_uuids',
|
|
||||||
'coolify_previous_artifact_app_uuids',
|
|
||||||
'previous_application_uuids',
|
|
||||||
'previous_app_uuids',
|
|
||||||
] as $key) {
|
|
||||||
if (!is_array($context[$key] ?? null)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
foreach ($context[$key] as $value) {
|
|
||||||
if (is_scalar($value)) {
|
|
||||||
$values[] = (string)$value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$activeUuid = trim($activeUuid);
|
|
||||||
$uuids = [];
|
|
||||||
foreach ($values as $value) {
|
|
||||||
$uuid = trim((string)$value);
|
|
||||||
if ($uuid === '' || $uuid === $activeUuid || in_array($uuid, $uuids, true)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$uuids[] = $uuid;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $uuids;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function updateCoolifyReleaseRuntimeEnv(coolify_api_client $client, string $resourceUuid, string $resourceType, array $target, array $context): ?array
|
private function updateCoolifyReleaseRuntimeEnv(coolify_api_client $client, string $resourceUuid, string $resourceType, array $target, array $context): ?array
|
||||||
{
|
{
|
||||||
$env = $this->releaseCoolifyRuntimeEnv($target, $context);
|
$env = $this->releaseCoolifyRuntimeEnv($target, $context);
|
||||||
@@ -6403,7 +6252,6 @@ class release_manager
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($resourceType === 'application') {
|
if ($resourceType === 'application') {
|
||||||
$this->deleteCoolifyGeneratedCommitEnvs($client, $resourceUuid, $target, $context);
|
|
||||||
$client->updateApplicationEnvsBulk($resourceUuid, $env);
|
$client->updateApplicationEnvsBulk($resourceUuid, $env);
|
||||||
} else {
|
} else {
|
||||||
$client->updateServiceEnvsBulk($resourceUuid, $env);
|
$client->updateServiceEnvsBulk($resourceUuid, $env);
|
||||||
@@ -6416,45 +6264,12 @@ class release_manager
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function deleteCoolifyGeneratedCommitEnvs(coolify_api_client $client, string $resourceUuid, array $target, array $context): void
|
|
||||||
{
|
|
||||||
$keys = $this->releaseCoolifyGeneratedCommitEnvKeys($target, $context);
|
|
||||||
if ($keys === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$rows = $this->payloadRows($client->listApplicationEnvs($resourceUuid));
|
|
||||||
} catch (Throwable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
if (!is_array($row)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$key = trim((string)($row['key'] ?? $row['name'] ?? ''));
|
|
||||||
$uuid = trim((string)($row['uuid'] ?? $row['id'] ?? ''));
|
|
||||||
if ($key === '' || $uuid === '' || !in_array($key, $keys, true)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$client->deleteApplicationEnv($resourceUuid, $uuid);
|
|
||||||
} catch (Throwable) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function releaseCoolifyRuntimeEnv(array $target, array $context): array
|
private function releaseCoolifyRuntimeEnv(array $target, array $context): array
|
||||||
{
|
{
|
||||||
$contextEnv = $this->releaseCoolifyContextEnv($context);
|
$contextEnv = $this->releaseCoolifyContextEnv($context);
|
||||||
$env = $contextEnv;
|
$env = $contextEnv;
|
||||||
$app = strtolower(trim((string)($target['app'] ?? '')));
|
$app = strtolower(trim((string)($target['app'] ?? '')));
|
||||||
$deploymentCommitSha = self::normalizeCommitSha($this->releaseCoolifyGitCommitSha($target, $context));
|
|
||||||
|
|
||||||
if ($app !== 'api') {
|
if ($app !== 'api') {
|
||||||
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
|
|
||||||
return $env;
|
return $env;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6476,37 +6291,21 @@ class release_manager
|
|||||||
$this->appendRuntimeEnvValue($env, $key, $value);
|
$this->appendRuntimeEnvValue($env, $key, $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$deploymentCommitSha = self::normalizeCommitSha($this->releaseCoolifyGitCommitSha($target, $context));
|
||||||
|
if ($deploymentCommitSha !== '') {
|
||||||
|
foreach (['API_COMMIT_SHA', 'COMMIT_SHA'] as $key) {
|
||||||
|
if (!array_key_exists($key, $contextEnv)) {
|
||||||
|
$env[$key] = $deploymentCommitSha;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$env = array_replace($env, $contextEnv);
|
$env = array_replace($env, $contextEnv);
|
||||||
$env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true';
|
$env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true';
|
||||||
$env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? ''));
|
$env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? ''));
|
||||||
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
|
|
||||||
return $this->normalizeCoolifyRuntimeEnv($env);
|
return $this->normalizeCoolifyRuntimeEnv($env);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function applyReleaseCoolifyCommitRuntimeEnv(array &$env, string $app, string $deploymentCommitSha): void
|
|
||||||
{
|
|
||||||
if ($deploymentCommitSha === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->releaseCoolifyGeneratedCommitEnvKeys(['app' => $app], []) as $key) {
|
|
||||||
$env[$key] = $deploymentCommitSha;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function releaseCoolifyGeneratedCommitEnvKeys(array $target, array $context): array
|
|
||||||
{
|
|
||||||
$app = strtolower(trim((string)($target['app'] ?? $context['app'] ?? '')));
|
|
||||||
if ($app === 'frontend') {
|
|
||||||
return ['SOURCE_COMMIT', 'RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'VITE_COMMIT_HASH'];
|
|
||||||
}
|
|
||||||
if ($app === 'api') {
|
|
||||||
return ['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function releaseCoolifyContextEnv(array $context): array
|
private function releaseCoolifyContextEnv(array $context): array
|
||||||
{
|
{
|
||||||
$env = [];
|
$env = [];
|
||||||
@@ -7214,13 +7013,6 @@ class release_manager
|
|||||||
|
|
||||||
private function releaseCoolifyGitCommitSha(array $target, array $context): string
|
private function releaseCoolifyGitCommitSha(array $target, array $context): string
|
||||||
{
|
{
|
||||||
foreach (['commit_sha', 'commit'] as $key) {
|
|
||||||
$value = trim((string)($target[$key] ?? ''));
|
|
||||||
if ($value !== '') {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ([
|
foreach ([
|
||||||
'coolify_git_commit_sha',
|
'coolify_git_commit_sha',
|
||||||
'git_commit_sha',
|
'git_commit_sha',
|
||||||
@@ -9136,7 +8928,7 @@ class release_manager
|
|||||||
if (array_keys($payload) === range(0, count($payload) - 1)) {
|
if (array_keys($payload) === range(0, count($payload) - 1)) {
|
||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
foreach (['data', 'services', 'projects', 'servers', 'github_apps', 'envs', 'environment_variables', 'results'] as $key) {
|
foreach (['data', 'services', 'projects', 'servers', 'github_apps', 'results'] as $key) {
|
||||||
if (is_array($payload[$key] ?? null)) {
|
if (is_array($payload[$key] ?? null)) {
|
||||||
return $this->payloadRows($payload[$key]);
|
return $this->payloadRows($payload[$key]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class selfserve_schema_bootstrap
|
|||||||
session_id INT NOT NULL,
|
session_id INT NOT NULL,
|
||||||
task_id INT NULL,
|
task_id INT NULL,
|
||||||
task_text VARCHAR(255) NOT NULL,
|
task_text VARCHAR(255) NOT NULL,
|
||||||
description TEXT NULL,
|
description VARCHAR(255) NULL,
|
||||||
services JSON NULL,
|
services JSON NULL,
|
||||||
buttons JSON NULL,
|
buttons JSON NULL,
|
||||||
dynamic_image_id INT NULL,
|
dynamic_image_id INT NULL,
|
||||||
@@ -197,18 +197,6 @@ class selfserve_schema_bootstrap
|
|||||||
'gate_ref_id',
|
'gate_ref_id',
|
||||||
'ALTER TABLE department_selfserve_tasks ADD COLUMN gate_ref_id INT NULL AFTER gate_type'
|
'ALTER TABLE department_selfserve_tasks ADD COLUMN gate_ref_id INT NULL AFTER gate_type'
|
||||||
);
|
);
|
||||||
self::ensureColumnDataType(
|
|
||||||
'department_selfserve_tasks',
|
|
||||||
'description',
|
|
||||||
['text', 'mediumtext', 'longtext'],
|
|
||||||
'ALTER TABLE department_selfserve_tasks MODIFY COLUMN description TEXT NULL AFTER task'
|
|
||||||
);
|
|
||||||
self::ensureColumnDataType(
|
|
||||||
'selfserve_wash_session_tasks',
|
|
||||||
'description',
|
|
||||||
['text', 'mediumtext', 'longtext'],
|
|
||||||
'ALTER TABLE selfserve_wash_session_tasks MODIFY COLUMN description TEXT NULL AFTER task_text'
|
|
||||||
);
|
|
||||||
self::ensureColumn(
|
self::ensureColumn(
|
||||||
'selfserve_wash_session_tasks',
|
'selfserve_wash_session_tasks',
|
||||||
'dynamic_images_vehicle_type',
|
'dynamic_images_vehicle_type',
|
||||||
@@ -251,49 +239,4 @@ class selfserve_schema_bootstrap
|
|||||||
}
|
}
|
||||||
$db->query($alterSql);
|
$db->query($alterSql);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int,string> $acceptedDataTypes
|
|
||||||
*/
|
|
||||||
public static function ensureColumnDataType(string $table, string $column, array $acceptedDataTypes, string $alterSql): void
|
|
||||||
{
|
|
||||||
global $db;
|
|
||||||
|
|
||||||
$columnInfo = self::columnInfo($table, $column);
|
|
||||||
if ($columnInfo === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$dataType = strtolower((string)($columnInfo['DATA_TYPE'] ?? ''));
|
|
||||||
$acceptedDataTypes = array_map(static fn(string $type): string => strtolower($type), $acceptedDataTypes);
|
|
||||||
if (in_array($dataType, $acceptedDataTypes, true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$db->query($alterSql);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string,mixed>|null
|
|
||||||
*/
|
|
||||||
public static function columnInfo(string $table, string $column): ?array
|
|
||||||
{
|
|
||||||
global $db;
|
|
||||||
$table = $db->escape_string($table);
|
|
||||||
$column = $db->escape_string($column);
|
|
||||||
$database = $db->escape_string($db->getDatabase());
|
|
||||||
|
|
||||||
$sql = "SELECT DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH
|
|
||||||
FROM information_schema.COLUMNS
|
|
||||||
WHERE TABLE_SCHEMA = '$database'
|
|
||||||
AND TABLE_NAME = '$table'
|
|
||||||
AND COLUMN_NAME = '$column'
|
|
||||||
LIMIT 1";
|
|
||||||
$result = $db->query($sql);
|
|
||||||
if (!$result) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return is_array($row) ? $row : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ class shelly implements shelly_i
|
|||||||
private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20;
|
private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20;
|
||||||
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;
|
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;
|
||||||
private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate';
|
private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate';
|
||||||
private const SHELLY_CONNECT_TIMEOUT_SECONDS = 2;
|
|
||||||
private const SHELLY_REQUEST_TIMEOUT_SECONDS = 5;
|
|
||||||
/**
|
/**
|
||||||
* @var array<int,array<string,mixed>>
|
* @var array<int,array<string,mixed>>
|
||||||
*/
|
*/
|
||||||
@@ -180,9 +178,6 @@ class shelly implements shelly_i
|
|||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||||
'Content-Type: application/json',
|
'Content-Type: application/json',
|
||||||
]);
|
]);
|
||||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::SHELLY_CONNECT_TIMEOUT_SECONDS);
|
|
||||||
curl_setopt($ch, CURLOPT_TIMEOUT, self::SHELLY_REQUEST_TIMEOUT_SECONDS);
|
|
||||||
curl_setopt($ch, CURLOPT_NOSIGNAL, true);
|
|
||||||
// Execute the request
|
// Execute the request
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
// Get the status code
|
// Get the status code
|
||||||
@@ -229,9 +224,6 @@ class shelly implements shelly_i
|
|||||||
);
|
);
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
curl_setopt($ch, CURLOPT_HTTPGET, true);
|
curl_setopt($ch, CURLOPT_HTTPGET, true);
|
||||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::SHELLY_CONNECT_TIMEOUT_SECONDS);
|
|
||||||
curl_setopt($ch, CURLOPT_TIMEOUT, self::SHELLY_REQUEST_TIMEOUT_SECONDS);
|
|
||||||
curl_setopt($ch, CURLOPT_NOSIGNAL, true);
|
|
||||||
|
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
|||||||
@@ -6,25 +6,13 @@ use GuzzleHttp\Client;
|
|||||||
use interfaces\notification_i;
|
use interfaces\notification_i;
|
||||||
use objects\departments_o;
|
use objects\departments_o;
|
||||||
use objects\users_o;
|
use objects\users_o;
|
||||||
use slack\slack_c;
|
|
||||||
use traits\notification_t;
|
use traits\notification_t;
|
||||||
|
|
||||||
require_once WD . '/modules/slack/slack_c.php';
|
|
||||||
|
|
||||||
class slack implements notification_i
|
class slack implements notification_i
|
||||||
{
|
{
|
||||||
use notification_t;
|
use notification_t;
|
||||||
|
|
||||||
private ?slack_c $config = null;
|
|
||||||
|
|
||||||
public function getConfig(): slack_c
|
|
||||||
{
|
|
||||||
if ($this->config === null) {
|
|
||||||
$this->config = new slack_c();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->config;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
@@ -134,7 +122,7 @@ class slack implements notification_i
|
|||||||
. "Status: $status";
|
. "Status: $status";
|
||||||
}
|
}
|
||||||
|
|
||||||
public function send_message(string $string, ?string $module = null): void
|
public function send_message(string $string, string $module = null): void
|
||||||
{
|
{
|
||||||
global $SLACK_DEFAULT_WEBHOOK;
|
global $SLACK_DEFAULT_WEBHOOK;
|
||||||
// Format the message if a module is provided
|
// Format the message if a module is provided
|
||||||
@@ -144,90 +132,4 @@ class slack implements notification_i
|
|||||||
// Send the message to the slack webhook
|
// Send the message to the slack webhook
|
||||||
self::add_log(self::send_webhook_message($string, $SLACK_DEFAULT_WEBHOOK));
|
self::add_log(self::send_webhook_message($string, $SLACK_DEFAULT_WEBHOOK));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
public function send_customer_registration_notification(int $customer_number): self
|
|
||||||
{
|
|
||||||
$webhook = $this->get_customer_registration_webhook_url();
|
|
||||||
if ($webhook === '') {
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
self::add_log(self::send_webhook_message(
|
|
||||||
$this->format_customer_registration($customer_number),
|
|
||||||
$webhook
|
|
||||||
));
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a sanitized customer-registration test notification to the saved Slack webhook.
|
|
||||||
*
|
|
||||||
* @return array{configured:bool,sent:bool,message:string}
|
|
||||||
*/
|
|
||||||
public function test_customer_registration_webhook(): array
|
|
||||||
{
|
|
||||||
$webhook = $this->get_customer_registration_webhook_url();
|
|
||||||
if ($webhook === '') {
|
|
||||||
return [
|
|
||||||
'configured' => false,
|
|
||||||
'sent' => false,
|
|
||||||
'message' => 'Slack customer registration webhook URL is not configured.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $this->send_webhook_message(
|
|
||||||
$this->format_customer_registration_test(),
|
|
||||||
$webhook
|
|
||||||
);
|
|
||||||
$sent = $this->is_webhook_send_successful($result);
|
|
||||||
|
|
||||||
self::add_log($sent
|
|
||||||
? 'Slack customer registration test webhook sent successfully.'
|
|
||||||
: 'Slack customer registration test webhook failed.'
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'configured' => true,
|
|
||||||
'sent' => $sent,
|
|
||||||
'message' => $sent
|
|
||||||
? 'Slack test message sent successfully.'
|
|
||||||
: 'Slack test message failed.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function get_customer_registration_webhook_url(): string
|
|
||||||
{
|
|
||||||
return trim((string)$this->getConfig()->customer_registration_webhook_url->getVariableValue());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function is_webhook_send_successful(string $result): bool
|
|
||||||
{
|
|
||||||
return !str_starts_with($result, 'Failed to send message:');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function format_customer_registration(int $customer_number): string
|
|
||||||
{
|
|
||||||
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
|
|
||||||
$customerName = $customer->exists()
|
|
||||||
? $customer->getCustomerName((int)$customer->customer_number->value())
|
|
||||||
: '';
|
|
||||||
$customerName = trim((string)$customerName);
|
|
||||||
if ($customerName === '') {
|
|
||||||
$customerName = 'Unknown customer';
|
|
||||||
}
|
|
||||||
|
|
||||||
$safeCustomerNumber = (int)$customer_number;
|
|
||||||
$customerUrl = 'https://truckwash.io/superuser/users?search=' . $safeCustomerNumber;
|
|
||||||
|
|
||||||
return "*New customer registered on Truck Wash*\n"
|
|
||||||
. "Customer: $customerName ($safeCustomerNumber)\n"
|
|
||||||
. "Open in Superuser: $customerUrl";
|
|
||||||
}
|
|
||||||
|
|
||||||
public function format_customer_registration_test(): string
|
|
||||||
{
|
|
||||||
return "*Truck Wash Slack test*\n"
|
|
||||||
. "Customer registration notifications are configured correctly.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "composer test:unit",
|
"test": "composer test:unit",
|
||||||
"analyse": "vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress",
|
|
||||||
"static": "@analyse",
|
|
||||||
"rector:dry-run": "@php -d error_reporting=0 -d display_errors=0 -d log_errors=0 vendor/bin/rector process --dry-run --config rector.php",
|
|
||||||
"rector:fix": "@php -d error_reporting=0 -d display_errors=0 -d log_errors=0 vendor/bin/rector process --config rector.php",
|
|
||||||
"test:unit": "vendor/bin/pest --testsuite=Unit --colors=always",
|
"test:unit": "vendor/bin/pest --testsuite=Unit --colors=always",
|
||||||
"test:integration": "vendor/bin/pest --testsuite=Integration --colors=always",
|
"test:integration": "vendor/bin/pest --testsuite=Integration --colors=always",
|
||||||
"test:api": [
|
"test:api": [
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ use classes\slack as Slack;
|
|||||||
use classes\email as Email;
|
use classes\email as Email;
|
||||||
use classes\gatewayapi as GatewayAPI;
|
use classes\gatewayapi as GatewayAPI;
|
||||||
use dynamicimages\images\machine_1;
|
use dynamicimages\images\machine_1;
|
||||||
use modules\selfserve\config\selfserve_dynamic_image_size_c;
|
|
||||||
use modules\selfserve\selfserve_c;
|
|
||||||
use goals\classes\goals_criteria;
|
use goals\classes\goals_criteria;
|
||||||
use goals\services\goals_progress_alert_renderer;
|
use goals\services\goals_progress_alert_renderer;
|
||||||
use goals\helpers\goals_criteria_progress_alert_destination as Dest;
|
use goals\helpers\goals_criteria_progress_alert_destination as Dest;
|
||||||
@@ -35,8 +33,6 @@ use objects\users_o;
|
|||||||
use routes\moduleWeatherAPIRoute;
|
use routes\moduleWeatherAPIRoute;
|
||||||
|
|
||||||
require_once __DIR__ . '/../classes/economic_transfer_executor.php';
|
require_once __DIR__ . '/../classes/economic_transfer_executor.php';
|
||||||
|
|
||||||
const DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH = 1600;
|
|
||||||
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
|
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
|
||||||
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
|
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
|
||||||
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
|
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
|
||||||
@@ -934,7 +930,6 @@ function buildDynamicImageCacheKey(array $variant): string
|
|||||||
'current_step' => (int)($variant['current_step'] ?? 0),
|
'current_step' => (int)($variant['current_step'] ?? 0),
|
||||||
'only_current_step' => (bool)($variant['only_current_step'] ?? false),
|
'only_current_step' => (bool)($variant['only_current_step'] ?? false),
|
||||||
'vehicle_type' => $variant['vehicle_type'] ?? null,
|
'vehicle_type' => $variant['vehicle_type'] ?? null,
|
||||||
'dynamic_image_size' => getSelfServeDynamicImageSizeModeForCron(),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$json = json_encode($cacheParams);
|
$json = json_encode($cacheParams);
|
||||||
@@ -967,11 +962,16 @@ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $cu
|
|||||||
$image->current_step = max(0, $currentStep);
|
$image->current_step = max(0, $currentStep);
|
||||||
$image->only_generate_current_step = $onlyCurrentStep;
|
$image->only_generate_current_step = $onlyCurrentStep;
|
||||||
$image->setup();
|
$image->setup();
|
||||||
if (getSelfServeDynamicImageSizeModeForCron() === selfserve_dynamic_image_size_c::SIZE_RELEVANT) {
|
|
||||||
$image->resizeToMaxWidth(DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $image->exportBinary('png');
|
$dataUri = $image->exportAsBase64('png');
|
||||||
|
if (!preg_match('/^data:image\/png;base64,(.*)$/', $dataUri, $matches)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$imageData = base64_decode($matches[1], true);
|
||||||
|
if ($imageData === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $imageData;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
warn('PreRenderDynamicImagesCron: render failed for dynamic_image_id=' . $dynamicImageId . ': ' . $e->getMessage());
|
warn('PreRenderDynamicImagesCron: render failed for dynamic_image_id=' . $dynamicImageId . ': ' . $e->getMessage());
|
||||||
return null;
|
return null;
|
||||||
@@ -985,19 +985,6 @@ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $cu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelfServeDynamicImageSizeModeForCron(): string
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$mode = (string)(new selfserve_c())->dynamic_image_size->getVariableValue();
|
|
||||||
} catch (Throwable) {
|
|
||||||
return selfserve_dynamic_image_size_c::SIZE_ORIGINAL;
|
|
||||||
}
|
|
||||||
|
|
||||||
return in_array($mode, [selfserve_dynamic_image_size_c::SIZE_ORIGINAL, selfserve_dynamic_image_size_c::SIZE_RELEVANT], true)
|
|
||||||
? $mode
|
|
||||||
: selfserve_dynamic_image_size_c::SIZE_ORIGINAL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param mixed $value
|
* @param mixed $value
|
||||||
* @return array<int|string>
|
* @return array<int|string>
|
||||||
|
|||||||
@@ -12,35 +12,10 @@ interface licenseplaterecognizer_i extends universal_module_i
|
|||||||
* @return array An array containing the plate number and other relevant information.
|
* @return array An array containing the plate number and other relevant information.
|
||||||
*/
|
*/
|
||||||
public function licenseplaterecognizer(string $base64_image): array;
|
public function licenseplaterecognizer(string $base64_image): array;
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the plate number from raw uploaded image bytes.
|
|
||||||
* @param string $image_data Raw uploaded image bytes.
|
|
||||||
* @param string $mime_type The image MIME type.
|
|
||||||
* @return array An array containing the plate number and other relevant information.
|
|
||||||
*/
|
|
||||||
public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the plate number from raw uploaded image bytes without building an exact-result cache key.
|
|
||||||
* @param string $image_data Raw uploaded image bytes.
|
|
||||||
* @param string $mime_type The image MIME type.
|
|
||||||
* @return array An array containing the plate number and other relevant information.
|
|
||||||
*/
|
|
||||||
public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the plate number from a PHP upload temp file without copying it into memory.
|
|
||||||
* @param string $image_path The uploaded image temp-file path.
|
|
||||||
* @param string $mime_type The image MIME type.
|
|
||||||
* @return array An array containing the plate number and other relevant information.
|
|
||||||
*/
|
|
||||||
public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get usage information about the license plate recognizer module.
|
* Get usage information about the license plate recognizer module.
|
||||||
* @returns licenseplaterecognizer_info An object containing usage statistics and information.
|
* @returns licenseplaterecognizer_info An object containing usage statistics and information.
|
||||||
* @see licenseplaterecognizer_info
|
* @see licenseplaterecognizer_info
|
||||||
*/
|
*/
|
||||||
public function get_usage(): licenseplaterecognizer_info;
|
public function get_usage(): licenseplaterecognizer_info;
|
||||||
}
|
}
|
||||||
@@ -8,10 +8,22 @@ require_once WD . '/modules/coolify/config/coolify_hetzner_load_balancer_id_c.ph
|
|||||||
require_once WD . '/modules/coolify/config/coolify_lb_automation_enabled_c.php';
|
require_once WD . '/modules/coolify/config/coolify_lb_automation_enabled_c.php';
|
||||||
require_once WD . '/modules/coolify/config/coolify_lb_automation_mode_c.php';
|
require_once WD . '/modules/coolify/config/coolify_lb_automation_mode_c.php';
|
||||||
require_once WD . '/modules/coolify/config/coolify_public_gateway_host_c.php';
|
require_once WD . '/modules/coolify/config/coolify_public_gateway_host_c.php';
|
||||||
|
require_once WD . '/modules/coolify/config/coolify_github_runner_backend_repository_c.php';
|
||||||
|
require_once WD . '/modules/coolify/config/coolify_github_runner_count_per_repo_c.php';
|
||||||
|
require_once WD . '/modules/coolify/config/coolify_github_runner_frontend_repository_c.php';
|
||||||
|
require_once WD . '/modules/coolify/config/coolify_github_runner_labels_c.php';
|
||||||
|
require_once WD . '/modules/coolify/config/coolify_github_runner_service_uuid_c.php';
|
||||||
|
require_once WD . '/modules/coolify/config/coolify_github_runner_token_c.php';
|
||||||
|
|
||||||
use modules\coolify\config\coolify_hetzner_cloud_api_token_c;
|
use modules\coolify\config\coolify_hetzner_cloud_api_token_c;
|
||||||
use modules\coolify\config\coolify_hetzner_load_balancer_id_c;
|
use modules\coolify\config\coolify_hetzner_load_balancer_id_c;
|
||||||
use modules\coolify\config\coolify_enabled_c;
|
use modules\coolify\config\coolify_enabled_c;
|
||||||
|
use modules\coolify\config\coolify_github_runner_backend_repository_c;
|
||||||
|
use modules\coolify\config\coolify_github_runner_count_per_repo_c;
|
||||||
|
use modules\coolify\config\coolify_github_runner_frontend_repository_c;
|
||||||
|
use modules\coolify\config\coolify_github_runner_labels_c;
|
||||||
|
use modules\coolify\config\coolify_github_runner_service_uuid_c;
|
||||||
|
use modules\coolify\config\coolify_github_runner_token_c;
|
||||||
use modules\coolify\config\coolify_lb_automation_enabled_c;
|
use modules\coolify\config\coolify_lb_automation_enabled_c;
|
||||||
use modules\coolify\config\coolify_lb_automation_mode_c;
|
use modules\coolify\config\coolify_lb_automation_mode_c;
|
||||||
use modules\coolify\config\coolify_public_gateway_host_c;
|
use modules\coolify\config\coolify_public_gateway_host_c;
|
||||||
@@ -29,6 +41,12 @@ class coolify_c
|
|||||||
public coolify_hetzner_load_balancer_id_c $hetzner_load_balancer_id;
|
public coolify_hetzner_load_balancer_id_c $hetzner_load_balancer_id;
|
||||||
public coolify_hetzner_cloud_api_token_c $hetzner_cloud_api_token;
|
public coolify_hetzner_cloud_api_token_c $hetzner_cloud_api_token;
|
||||||
public coolify_public_gateway_host_c $public_gateway_host;
|
public coolify_public_gateway_host_c $public_gateway_host;
|
||||||
|
public coolify_github_runner_token_c $github_runner_token;
|
||||||
|
public coolify_github_runner_service_uuid_c $github_runner_service_uuid;
|
||||||
|
public coolify_github_runner_frontend_repository_c $github_runner_frontend_repository;
|
||||||
|
public coolify_github_runner_backend_repository_c $github_runner_backend_repository;
|
||||||
|
public coolify_github_runner_labels_c $github_runner_labels;
|
||||||
|
public coolify_github_runner_count_per_repo_c $github_runner_count_per_repo;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
@@ -40,6 +58,12 @@ class coolify_c
|
|||||||
coolify_hetzner_load_balancer_id_c::class,
|
coolify_hetzner_load_balancer_id_c::class,
|
||||||
coolify_hetzner_cloud_api_token_c::class,
|
coolify_hetzner_cloud_api_token_c::class,
|
||||||
coolify_public_gateway_host_c::class,
|
coolify_public_gateway_host_c::class,
|
||||||
|
coolify_github_runner_token_c::class,
|
||||||
|
coolify_github_runner_service_uuid_c::class,
|
||||||
|
coolify_github_runner_frontend_repository_c::class,
|
||||||
|
coolify_github_runner_backend_repository_c::class,
|
||||||
|
coolify_github_runner_labels_c::class,
|
||||||
|
coolify_github_runner_count_per_repo_c::class,
|
||||||
]);
|
]);
|
||||||
$this->enabled = new coolify_enabled_c();
|
$this->enabled = new coolify_enabled_c();
|
||||||
$this->lb_automation_enabled = new coolify_lb_automation_enabled_c();
|
$this->lb_automation_enabled = new coolify_lb_automation_enabled_c();
|
||||||
@@ -47,12 +71,18 @@ class coolify_c
|
|||||||
$this->hetzner_load_balancer_id = new coolify_hetzner_load_balancer_id_c();
|
$this->hetzner_load_balancer_id = new coolify_hetzner_load_balancer_id_c();
|
||||||
$this->hetzner_cloud_api_token = new coolify_hetzner_cloud_api_token_c();
|
$this->hetzner_cloud_api_token = new coolify_hetzner_cloud_api_token_c();
|
||||||
$this->public_gateway_host = new coolify_public_gateway_host_c();
|
$this->public_gateway_host = new coolify_public_gateway_host_c();
|
||||||
|
$this->github_runner_token = new coolify_github_runner_token_c();
|
||||||
|
$this->github_runner_service_uuid = new coolify_github_runner_service_uuid_c();
|
||||||
|
$this->github_runner_frontend_repository = new coolify_github_runner_frontend_repository_c();
|
||||||
|
$this->github_runner_backend_repository = new coolify_github_runner_backend_repository_c();
|
||||||
|
$this->github_runner_labels = new coolify_github_runner_labels_c();
|
||||||
|
$this->github_runner_count_per_repo = new coolify_github_runner_count_per_repo_c();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getConfigRequest(): array
|
public function getConfigRequest(): array
|
||||||
{
|
{
|
||||||
return array_map(static function (array $row): array {
|
return array_map(static function (array $row): array {
|
||||||
if (($row['variable'] ?? '') === 'hetzner_cloud_api_token') {
|
if (in_array(($row['variable'] ?? ''), ['hetzner_cloud_api_token', 'github_runner_token'], true)) {
|
||||||
$secretSet = trim((string)($row['value'] ?? '')) !== '';
|
$secretSet = trim((string)($row['value'] ?? '')) !== '';
|
||||||
$row['value'] = $secretSet ? '[redacted]' : '';
|
$row['value'] = $secretSet ? '[redacted]' : '';
|
||||||
$row['secret_set'] = $secretSet;
|
$row['secret_set'] = $secretSet;
|
||||||
|
|||||||
@@ -86,14 +86,6 @@ interface dynamicimages_image_i
|
|||||||
*/
|
*/
|
||||||
public function exportAsBase64(?string $format = null, int $quality = 90): string;
|
public function exportAsBase64(?string $format = null, int $quality = 90): string;
|
||||||
|
|
||||||
/**
|
|
||||||
* Export the composed image as binary image data.
|
|
||||||
* @param string|null $format Optional target format (e.g. 'png', 'jpeg')
|
|
||||||
* @param int $quality Quality for lossy formats (0-100)
|
|
||||||
* @return string binary image data
|
|
||||||
*/
|
|
||||||
public function exportBinary(?string $format = null, int $quality = 90): string;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Directly serve the composed image to the client with proper headers.
|
* Directly serve the composed image to the client with proper headers.
|
||||||
* Convenience wrapper for outputting binary image data.
|
* Convenience wrapper for outputting binary image data.
|
||||||
@@ -102,4 +94,4 @@ interface dynamicimages_image_i
|
|||||||
* @param int $quality Quality for lossy formats (0-100)
|
* @param int $quality Quality for lossy formats (0-100)
|
||||||
*/
|
*/
|
||||||
public function servePicture(?string $format = null, int $quality = 90): void;
|
public function servePicture(?string $format = null, int $quality = 90): void;
|
||||||
}
|
}
|
||||||
@@ -226,20 +226,6 @@ trait dynamicimages_image_t
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resizeToMaxWidth(int $maxWidth): dynamicimages_image_i
|
|
||||||
{
|
|
||||||
$this->assertCanvasInitialized();
|
|
||||||
if ($maxWidth <= 0) {
|
|
||||||
throw new \InvalidArgumentException('Resize max width must be a positive integer.');
|
|
||||||
}
|
|
||||||
if ($this->canvasWidth === null || $this->canvasHeight === null || $this->canvasWidth <= $maxWidth) {
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
$height = (int)round($this->canvasHeight * ($maxWidth / $this->canvasWidth));
|
|
||||||
return $this->resize($maxWidth, max(1, $height));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function crop(int $width, int $height, int $x, int $y): dynamicimages_image_i
|
public function crop(int $width, int $height, int $x, int $y): dynamicimages_image_i
|
||||||
{
|
{
|
||||||
$this->assertCanvasInitialized();
|
$this->assertCanvasInitialized();
|
||||||
@@ -321,8 +307,20 @@ trait dynamicimages_image_t
|
|||||||
*/
|
*/
|
||||||
public function exportAsBase64(?string $format = null, int $quality = 90): string
|
public function exportAsBase64(?string $format = null, int $quality = 90): string
|
||||||
{
|
{
|
||||||
|
// If a canvas is initialized, export that as PNG by default
|
||||||
if ($this->image instanceof \Imagick) {
|
if ($this->image instanceof \Imagick) {
|
||||||
return 'data:image/png;base64,' . base64_encode($this->exportBinary($format, $quality));
|
$img = clone $this->image;
|
||||||
|
$img->setImageFormat('png');
|
||||||
|
// Quality influences compression for PNG differently; keep as hint
|
||||||
|
if ($format !== null && strtolower($format) !== 'png') {
|
||||||
|
// For now we only support PNG for composed images as requested
|
||||||
|
}
|
||||||
|
// Strip metadata to reduce size
|
||||||
|
$img->stripImage();
|
||||||
|
$blob = $img->getImageBlob();
|
||||||
|
$img->clear();
|
||||||
|
$img->destroy();
|
||||||
|
return 'data:image/png;base64,' . base64_encode($blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: export first asset as-is
|
// Fallback: export first asset as-is
|
||||||
@@ -343,40 +341,6 @@ trait dynamicimages_image_t
|
|||||||
return 'data:' . $mime . ';base64,' . base64_encode($data);
|
return 'data:' . $mime . ';base64,' . base64_encode($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function exportBinary(?string $format = null, int $quality = 90): string
|
|
||||||
{
|
|
||||||
// If a canvas is initialized, export that as PNG by default
|
|
||||||
if ($this->image instanceof \Imagick) {
|
|
||||||
$img = clone $this->image;
|
|
||||||
$img->setImageFormat('png');
|
|
||||||
// Quality influences compression for PNG differently; keep as hint
|
|
||||||
if ($format !== null && strtolower($format) !== 'png') {
|
|
||||||
// For now we only support PNG for composed images as requested
|
|
||||||
}
|
|
||||||
// Strip metadata to reduce size
|
|
||||||
$img->stripImage();
|
|
||||||
$blob = $img->getImageBlob();
|
|
||||||
$img->clear();
|
|
||||||
$img->destroy();
|
|
||||||
return $blob;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: export first asset as-is
|
|
||||||
if (empty($this->assets)) {
|
|
||||||
throw new \RuntimeException('No assets available to export.');
|
|
||||||
}
|
|
||||||
$asset = $this->assets[0];
|
|
||||||
$path = $asset->getPath();
|
|
||||||
if (!is_readable($path)) {
|
|
||||||
throw new \RuntimeException('Asset is not readable: ' . $path);
|
|
||||||
}
|
|
||||||
$data = file_get_contents($path);
|
|
||||||
if ($data === false) {
|
|
||||||
throw new \RuntimeException('Failed to read asset: ' . $path);
|
|
||||||
}
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getAsset(string $asset_name): ?dynamicimages_asset
|
public function getAsset(string $asset_name): ?dynamicimages_asset
|
||||||
{
|
{
|
||||||
foreach ($this->assets as $asset) {
|
foreach ($this->assets as $asset) {
|
||||||
@@ -393,18 +357,22 @@ trait dynamicimages_image_t
|
|||||||
*/
|
*/
|
||||||
public function outputImage(?string $format = null, int $quality = 90): void
|
public function outputImage(?string $format = null, int $quality = 90): void
|
||||||
{
|
{
|
||||||
$mimeType = 'image/png';
|
$dataUri = $this->exportAsBase64($format, $quality);
|
||||||
if (!$this->image instanceof \Imagick && !empty($this->assets)) {
|
// Extract mime type and base64 data
|
||||||
$asset = $this->assets[0];
|
if (preg_match('/^data:(image\/[a-zA-Z0-9+.-]+);base64,(.*)$/', $dataUri, $matches)) {
|
||||||
$path = $asset->getPath();
|
$mimeType = $matches[1];
|
||||||
$imgInfo = is_readable($path) ? @getimagesize($path) : false;
|
$base64Data = $matches[2];
|
||||||
$mimeType = is_array($imgInfo) && isset($imgInfo['mime']) ? $imgInfo['mime'] : 'application/octet-stream';
|
// Decode base64 data
|
||||||
|
$imageData = base64_decode($base64Data);
|
||||||
|
if ($imageData !== false) {
|
||||||
|
// Send appropriate headers
|
||||||
|
header('Content-Type: ' . $mimeType);
|
||||||
|
header('Content-Length: ' . strlen($imageData));
|
||||||
|
// Output the image data
|
||||||
|
echo $imageData;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$imageData = $this->exportBinary($format, $quality);
|
|
||||||
header('Content-Type: ' . $mimeType);
|
|
||||||
header('Content-Length: ' . strlen($imageData));
|
|
||||||
echo $imageData;
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -414,4 +382,4 @@ trait dynamicimages_image_t
|
|||||||
{
|
{
|
||||||
$this->outputImage($format, $quality);
|
$this->outputImage($format, $quality);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
namespace email\templates;
|
namespace email\templates;
|
||||||
|
|
||||||
use email\helpers\email_template;
|
use email\helpers\email_template;use objects\users_o;
|
||||||
use objects\users_o;
|
|
||||||
|
|
||||||
class email_template_new_customer
|
class email_template_new_customer
|
||||||
{
|
{
|
||||||
@@ -53,7 +52,6 @@ class email_template_new_customer
|
|||||||
*/
|
*/
|
||||||
public function generate_html(): string
|
public function generate_html(): string
|
||||||
{
|
{
|
||||||
$customer_label = htmlspecialchars($this->getCustomerRegistrationLabel(), ENT_QUOTES, 'UTF-8');
|
|
||||||
ob_start();
|
ob_start();
|
||||||
# Start of the html
|
# Start of the html
|
||||||
?>
|
?>
|
||||||
@@ -75,7 +73,7 @@ class email_template_new_customer
|
|||||||
|
|
||||||
<!-- Intro -->
|
<!-- Intro -->
|
||||||
<p class="container-text-md" style="color:#000000;font-size:16px;line-height:1.5;margin:0 0 18px 0;mso-line-height-rule:exactly;">
|
<p class="container-text-md" style="color:#000000;font-size:16px;line-height:1.5;margin:0 0 18px 0;mso-line-height-rule:exactly;">
|
||||||
Tak for din registrering af <?=$customer_label?> som kunde hos Truck Wash.
|
Tak for din registrering af <?=((new users_o())->getCustomerName((int)$this->customer_number))?><?=(((new users_o())->getCustomerEcocomicData((int)$this->customer_number)->economic_customer->corporateIdentificationNumber) ? ' (' . (new users_o())->getCustomerEcocomicData((int)$this->customer_number)->economic_customer->corporateIdentificationNumber . ')' : '')?> som kunde hos Truck Wash.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- You can now wash your trucks -->
|
<!-- You can now wash your trucks -->
|
||||||
@@ -187,19 +185,4 @@ class email_template_new_customer
|
|||||||
# End of the html
|
# End of the html
|
||||||
return ob_get_clean();
|
return ob_get_clean();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getCustomerRegistrationLabel(): string
|
|
||||||
{
|
|
||||||
$customer = (new users_o())->getUserByCustomerNumber($this->customer_number);
|
|
||||||
$customer_name = trim((string)($customer->getCustomerName($this->customer_number) ?? ''));
|
|
||||||
$customer_label = $customer_name === '' ? 'virksomhed (CVR)' : $customer_name;
|
|
||||||
|
|
||||||
$customer->getCustomerEcocomicData($this->customer_number);
|
|
||||||
$corporate_identification_number = trim((string)($customer->economic_customer->corporateIdentificationNumber ?? ''));
|
|
||||||
if ($corporate_identification_number !== '') {
|
|
||||||
$customer_label .= ' (' . $corporate_identification_number . ')';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $customer_label;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ class selfserve_lane_command_arguments
|
|||||||
public ?string $license_plate = null;
|
public ?string $license_plate = null;
|
||||||
public ?int $customer_number = null;
|
public ?int $customer_number = null;
|
||||||
public ?int $subuser_id = null;
|
public ?int $subuser_id = null;
|
||||||
public ?string $wash_mode = null;
|
|
||||||
public bool $defer_relay_side_effects = false;
|
public bool $defer_relay_side_effects = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,22 +32,6 @@ class selfserve_lane_command_arguments
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setWashMode(?string $wash_mode): self
|
|
||||||
{
|
|
||||||
$normalized = strtolower(trim((string)$wash_mode));
|
|
||||||
if ($wash_mode === null || $normalized === '') {
|
|
||||||
$this->wash_mode = null;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!in_array($normalized, ['manual', 'machine'], true)) {
|
|
||||||
throw new \InvalidArgumentException('Invalid wash type: ' . $wash_mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->wash_mode = $normalized;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self
|
public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self
|
||||||
{
|
{
|
||||||
$this->defer_relay_side_effects = $defer_relay_side_effects;
|
$this->defer_relay_side_effects = $defer_relay_side_effects;
|
||||||
@@ -67,12 +50,6 @@ class selfserve_lane_command_arguments
|
|||||||
if (array_key_exists('subuser_id', $params)) {
|
if (array_key_exists('subuser_id', $params)) {
|
||||||
$this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']);
|
$this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']);
|
||||||
}
|
}
|
||||||
if (array_key_exists('wash_type', $params)) {
|
|
||||||
$this->setWashMode($params['wash_type'] === null ? null : (string)$params['wash_type']);
|
|
||||||
}
|
|
||||||
if (array_key_exists('wash_mode', $params)) {
|
|
||||||
$this->setWashMode($params['wash_mode'] === null ? null : (string)$params['wash_mode']);
|
|
||||||
}
|
|
||||||
if (array_key_exists('defer_relay_side_effects', $params)) {
|
if (array_key_exists('defer_relay_side_effects', $params)) {
|
||||||
$this->setDeferRelaySideEffects(filter_var(
|
$this->setDeferRelaySideEffects(filter_var(
|
||||||
$params['defer_relay_side_effects'],
|
$params['defer_relay_side_effects'],
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ class selfserve_studio_graph
|
|||||||
{
|
{
|
||||||
private const DEFAULT_PATH_MAX_STATES = 2048;
|
private const DEFAULT_PATH_MAX_STATES = 2048;
|
||||||
private const MAX_PATH_MAX_STATES = 2048;
|
private const MAX_PATH_MAX_STATES = 2048;
|
||||||
private const DEFAULT_PATH_SAMPLE_LIMIT = 2048;
|
private const DEFAULT_PATH_SAMPLE_LIMIT = 200;
|
||||||
private const MAX_PATH_SAMPLE_LIMIT = 2048;
|
private const MAX_PATH_SAMPLE_LIMIT = 200;
|
||||||
|
|
||||||
/** @var array<string,array<int,string>> */
|
/** @var array<string,array<int,string>> */
|
||||||
private array $columnCache = [];
|
private array $columnCache = [];
|
||||||
|
|||||||
@@ -103,79 +103,49 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array
|
public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array
|
||||||
{
|
{
|
||||||
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
|
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
|
||||||
$mutationResult = $this->withSessionMutationLock(
|
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
|
||||||
$laneId,
|
|
||||||
$snapshot['reg'],
|
|
||||||
$snapshot['customer_number'],
|
|
||||||
function () use ($laneId, $snapshot, $options): array {
|
|
||||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
|
|
||||||
$createSession = (bool)($options['create_session'] ?? true);
|
|
||||||
|
|
||||||
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
|
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
|
||||||
return [
|
return $session->exists()
|
||||||
'session' => $session,
|
? $this->getSessionSummary((int)$session->id)
|
||||||
'response' => $session->exists()
|
: $this->formatBlockedSessionSummary($snapshot);
|
||||||
? $this->getSessionSummary((int)$session->id)
|
|
||||||
: $this->formatBlockedSessionSummary($snapshot),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$session->exists() && !$createSession) {
|
|
||||||
return [
|
|
||||||
'session' => $session,
|
|
||||||
'response' => $this->formatSnapshotResponse($snapshot, null),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$session->exists()) {
|
|
||||||
$session = (new selfserve_wash_sessions_o())->add(
|
|
||||||
$laneId,
|
|
||||||
(int)$snapshot['lane']['department'],
|
|
||||||
$snapshot['machine_type']['id'] ?? null,
|
|
||||||
$snapshot['customer_number'],
|
|
||||||
$snapshot['reg'],
|
|
||||||
$snapshot['vehicle']['id'] ?? null,
|
|
||||||
$snapshot['vehicle']['type'] ?? null,
|
|
||||||
$this->deriveBaseStatus($snapshot),
|
|
||||||
(bool)$snapshot['allowed'],
|
|
||||||
$this->buildSessionMetadata($snapshot),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
$session->machine_type_id->set($snapshot['machine_type']['id'] ?? null);
|
|
||||||
$session->customer_number->set($snapshot['customer_number']);
|
|
||||||
$session->vehicle_id->set($snapshot['vehicle']['id'] ?? null);
|
|
||||||
$session->vehicle_type_id->set($snapshot['vehicle_type_id']);
|
|
||||||
$session->reg->set($snapshot['reg']);
|
|
||||||
$session->allowed->set((bool)$snapshot['allowed']);
|
|
||||||
$session->metadata_json->set($this->buildSessionMetadata($snapshot));
|
|
||||||
$session->updateStatus($this->deriveCurrentStatus($snapshot, $session));
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->syncSessionAnswers((int)$session->id, $snapshot['questions']);
|
|
||||||
$this->syncSessionTasks((int)$session->id, $snapshot['tasks']);
|
|
||||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_SYNCED, [
|
|
||||||
'allowed' => (bool)$snapshot['allowed'],
|
|
||||||
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
|
||||||
'allowed_services' => $snapshot['allowed_services'],
|
|
||||||
'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'session' => $session,
|
|
||||||
'response' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$session = $mutationResult['session'];
|
|
||||||
if ($mutationResult['response'] !== null) {
|
|
||||||
return $mutationResult['response'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!$session->exists()) {
|
||||||
|
$session = (new selfserve_wash_sessions_o())->add(
|
||||||
|
$laneId,
|
||||||
|
(int)$snapshot['lane']['department'],
|
||||||
|
$snapshot['machine_type']['id'] ?? null,
|
||||||
|
$snapshot['customer_number'],
|
||||||
|
$snapshot['reg'],
|
||||||
|
$snapshot['vehicle']['id'] ?? null,
|
||||||
|
$snapshot['vehicle']['type'] ?? null,
|
||||||
|
$this->deriveBaseStatus($snapshot),
|
||||||
|
(bool)$snapshot['allowed'],
|
||||||
|
$this->buildSessionMetadata($snapshot),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$session->machine_type_id->set($snapshot['machine_type']['id'] ?? null);
|
||||||
|
$session->customer_number->set($snapshot['customer_number']);
|
||||||
|
$session->vehicle_id->set($snapshot['vehicle']['id'] ?? null);
|
||||||
|
$session->vehicle_type_id->set($snapshot['vehicle_type_id']);
|
||||||
|
$session->reg->set($snapshot['reg']);
|
||||||
|
$session->allowed->set((bool)$snapshot['allowed']);
|
||||||
|
$session->metadata_json->set($this->buildSessionMetadata($snapshot));
|
||||||
|
$session->updateStatus($this->deriveCurrentStatus($snapshot, $session));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->syncSessionAnswers((int)$session->id, $snapshot['questions']);
|
||||||
|
$this->syncSessionTasks((int)$session->id, $snapshot['tasks']);
|
||||||
|
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_SYNCED, [
|
||||||
|
'allowed' => (bool)$snapshot['allowed'],
|
||||||
|
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
||||||
|
'allowed_services' => $snapshot['allowed_services'],
|
||||||
|
'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
|
||||||
|
]);
|
||||||
|
|
||||||
if ($syncRelayState) {
|
if ($syncRelayState) {
|
||||||
if ($session->exists()) {
|
$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);
|
||||||
$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->getSessionSummary((int)$session->id);
|
return $this->getSessionSummary((int)$session->id);
|
||||||
@@ -425,7 +395,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
return $this->getSessionSummary((int)$session->id);
|
return $this->getSessionSummary((int)$session->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array
|
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array
|
||||||
{
|
{
|
||||||
$session = $reg !== null
|
$session = $reg !== null
|
||||||
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber)
|
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber)
|
||||||
@@ -435,12 +405,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$session->markCompletedIfOpen($orderId)) {
|
$session->markCompleted($orderId);
|
||||||
return $this->getSessionSummary((int)$session->id);
|
$this->disableMachineRelayForCompletedWash($laneId);
|
||||||
}
|
|
||||||
if ($disableRelays) {
|
|
||||||
$this->disableMachineRelayForCompletedWash($laneId);
|
|
||||||
}
|
|
||||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [
|
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [
|
||||||
'lane_id' => $laneId,
|
'lane_id' => $laneId,
|
||||||
'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
|
'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
|
||||||
@@ -484,12 +450,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
'runtime_before_reset' => $runtimeSnapshot,
|
'runtime_before_reset' => $runtimeSnapshot,
|
||||||
'forced_at' => date('Y-m-d H:i:s'),
|
'forced_at' => date('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
if (!$session->markForceStoppedIfOpen($orderId, $eventPayload)) {
|
$session->markForceStopped($orderId, $eventPayload);
|
||||||
$summary = $this->getSessionSummary((int)$session->id);
|
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_FORCE_STOPPED, $eventPayload);
|
||||||
} else {
|
$summary = $this->getSessionSummary((int)$session->id);
|
||||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_FORCE_STOPPED, $eventPayload);
|
|
||||||
$summary = $this->getSessionSummary((int)$session->id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$lane->execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments());
|
$lane->execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments());
|
||||||
@@ -2999,7 +2962,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
|
|
||||||
return array_values(array_filter($questions, static function (array $question) use ($departmentId, $laneId, $vehicleTypeId): bool {
|
return array_values(array_filter($questions, static function (array $question) use ($departmentId, $laneId, $vehicleTypeId): bool {
|
||||||
return (int)($question['department'] ?? 0) === $departmentId
|
return (int)($question['department'] ?? 0) === $departmentId
|
||||||
&& ((int)($question['lane'] ?? 0) === 0 || (int)($question['lane'] ?? 0) === $laneId)
|
&& (int)($question['lane'] ?? 0) === $laneId
|
||||||
&& (int)($question['product'] ?? 0) === $vehicleTypeId;
|
&& (int)($question['product'] ?? 0) === $vehicleTypeId;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -3042,7 +3005,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
return array_values(array_filter($conditions, static function (array $condition) use ($departmentId, $laneId, $vehicleTypeId): bool {
|
return array_values(array_filter($conditions, static function (array $condition) use ($departmentId, $laneId, $vehicleTypeId): bool {
|
||||||
return (int)($condition['machine_type_id'] ?? 0) === 0
|
return (int)($condition['machine_type_id'] ?? 0) === 0
|
||||||
&& (int)($condition['department'] ?? 0) === $departmentId
|
&& (int)($condition['department'] ?? 0) === $departmentId
|
||||||
&& ((int)($condition['lane'] ?? 0) === 0 || (int)($condition['lane'] ?? 0) === $laneId)
|
&& (int)($condition['lane'] ?? 0) === $laneId
|
||||||
&& (int)($condition['product'] ?? 0) === $vehicleTypeId;
|
&& (int)($condition['product'] ?? 0) === $vehicleTypeId;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -3086,7 +3049,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
return array_values(array_filter($tasks, static function (array $task) use ($departmentId, $laneId, $vehicleTypeId): bool {
|
return array_values(array_filter($tasks, static function (array $task) use ($departmentId, $laneId, $vehicleTypeId): bool {
|
||||||
return (int)($task['machine_type_id'] ?? 0) === 0
|
return (int)($task['machine_type_id'] ?? 0) === 0
|
||||||
&& (int)($task['department'] ?? 0) === $departmentId
|
&& (int)($task['department'] ?? 0) === $departmentId
|
||||||
&& ((int)($task['lane'] ?? 0) === 0 || (int)($task['lane'] ?? 0) === $laneId)
|
&& (int)($task['lane'] ?? 0) === $laneId
|
||||||
&& (int)($task['product'] ?? 0) === $vehicleTypeId;
|
&& (int)($task['product'] ?? 0) === $vehicleTypeId;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -3225,86 +3188,6 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
|||||||
(new selfserve_wash_session_events_o())->add($sessionId, $eventType, $payload);
|
(new selfserve_wash_session_events_o())->add($sessionId, $eventType, $payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param callable():array<string,mixed> $callback
|
|
||||||
* @return array<string,mixed>
|
|
||||||
*/
|
|
||||||
protected function withSessionMutationLock(int $laneId, string $reg, ?int $customerNumber, callable $callback): array
|
|
||||||
{
|
|
||||||
$lockKey = $this->sessionMutationLockKey($laneId, $reg, $customerNumber);
|
|
||||||
$lock = $this->acquireSessionMutationLock($lockKey);
|
|
||||||
|
|
||||||
try {
|
|
||||||
return $callback();
|
|
||||||
} finally {
|
|
||||||
$this->releaseSessionMutationLock($lock);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{driver:string,key:string,token:?string}
|
|
||||||
*/
|
|
||||||
protected function acquireSessionMutationLock(string $lockKey): array
|
|
||||||
{
|
|
||||||
if (defined('redis') && method_exists(redis, 'set_if_absent_with_expiration')) {
|
|
||||||
$token = bin2hex(random_bytes(16));
|
|
||||||
if (!redis->set_if_absent_with_expiration($lockKey, $token, 15)) {
|
|
||||||
throw new \RuntimeException('Self-serve wash session is busy. Try again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'driver' => 'redis',
|
|
||||||
'key' => $lockKey,
|
|
||||||
'token' => $token,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
global $db;
|
|
||||||
$result = $db->query("SELECT GET_LOCK('" . $db->escape_string($lockKey) . "', 5) AS acquired");
|
|
||||||
$row = $db->fetch_assoc($result);
|
|
||||||
if ((int)($row['acquired'] ?? 0) !== 1) {
|
|
||||||
throw new \RuntimeException('Self-serve wash session is busy. Try again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'driver' => 'mysql',
|
|
||||||
'key' => $lockKey,
|
|
||||||
'token' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{driver:string,key:string,token:?string} $lock
|
|
||||||
*/
|
|
||||||
protected function releaseSessionMutationLock(array $lock): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
if ($lock['driver'] === 'redis' && defined('redis')) {
|
|
||||||
if (method_exists(redis, 'get') && redis->get($lock['key']) !== $lock['token']) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (method_exists(redis, 'delete')) {
|
|
||||||
redis->delete($lock['key']);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($lock['driver'] === 'mysql') {
|
|
||||||
global $db;
|
|
||||||
$db->query("SELECT RELEASE_LOCK('" . $db->escape_string($lock['key']) . "')");
|
|
||||||
}
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// Locks have TTLs or connection scope; release failures must not mask API results.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function sessionMutationLockKey(int $laneId, string $reg, ?int $customerNumber): string
|
|
||||||
{
|
|
||||||
return 'selfserve_session_mutation:' . (int)$laneId . ':' . sha1(
|
|
||||||
selfserve::standardize_registration($reg) . ':' . ($customerNumber === null ? 'anon' : (string)(int)$customerNumber)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function buildSessionMetadata(array $snapshot): array
|
protected function buildSessionMetadata(array $snapshot): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace modules\selfserve\config;
|
|
||||||
|
|
||||||
use Exception;
|
|
||||||
use traits\module_config_variable;
|
|
||||||
|
|
||||||
class selfserve_dynamic_image_size_c
|
|
||||||
{
|
|
||||||
use module_config_variable;
|
|
||||||
|
|
||||||
public const SIZE_ORIGINAL = 'original';
|
|
||||||
public const SIZE_RELEVANT = 'relevant';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
self::setupConfigVariable(
|
|
||||||
'selfserve',
|
|
||||||
'dynamic_image_size',
|
|
||||||
'string',
|
|
||||||
true,
|
|
||||||
[self::SIZE_ORIGINAL, self::SIZE_RELEVANT],
|
|
||||||
'Whether self-serve dynamic images are served in the original rendered size or resized to the relevant terminal size',
|
|
||||||
self::SIZE_RELEVANT,
|
|
||||||
false,
|
|
||||||
self::SIZE_ORIGINAL
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@ interface selfserve_wash_flow_i
|
|||||||
|
|
||||||
public function getLatestSessionSummary(int $laneId, string $reg): array;
|
public function getLatestSessionSummary(int $laneId, string $reg): array;
|
||||||
|
|
||||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array;
|
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array;
|
||||||
|
|
||||||
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array;
|
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -439,7 +439,7 @@ Public methods:
|
|||||||
| `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. |
|
| `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. |
|
||||||
| `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. |
|
| `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. |
|
||||||
| `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. |
|
| `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. |
|
||||||
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true)` | STOP has finished and you want to close the latest open session. Normal STOP passes `false` because it already disabled relays before opening the exit port. | Full summary, or `null` if no open session exists. |
|
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null)` | STOP has finished and you want to close the latest open session. | Full summary, or `null` if no open session exists. |
|
||||||
|
|
||||||
Key implementation details:
|
Key implementation details:
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ namespace modules\selfserve;
|
|||||||
require_once WD . '/modules/selfserve/config/selfserve_enabled_c.php';
|
require_once WD . '/modules/selfserve/config/selfserve_enabled_c.php';
|
||||||
require_once WD . '/modules/selfserve/config/selfserve_minute_product_c.php';
|
require_once WD . '/modules/selfserve/config/selfserve_minute_product_c.php';
|
||||||
require_once WD . '/modules/selfserve/config/selfserve_machine_wash_minutes_included_c.php';
|
require_once WD . '/modules/selfserve/config/selfserve_machine_wash_minutes_included_c.php';
|
||||||
require_once WD . '/modules/selfserve/config/selfserve_dynamic_image_size_c.php';
|
|
||||||
|
|
||||||
use modules\selfserve\config\selfserve_dynamic_image_size_c;
|
|
||||||
use modules\selfserve\config\selfserve_enabled_c;
|
use modules\selfserve\config\selfserve_enabled_c;
|
||||||
use modules\selfserve\config\selfserve_machine_wash_minutes_included_c;
|
use modules\selfserve\config\selfserve_machine_wash_minutes_included_c;
|
||||||
use modules\selfserve\config\selfserve_minute_product_c;
|
use modules\selfserve\config\selfserve_minute_product_c;
|
||||||
@@ -31,11 +29,6 @@ class selfserve_c
|
|||||||
* @var selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included
|
* @var selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included
|
||||||
*/
|
*/
|
||||||
public selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included;
|
public selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included;
|
||||||
/**
|
|
||||||
* Dynamic image output size mode for self-serve terminals
|
|
||||||
* @var selfserve_dynamic_image_size_c $dynamic_image_size
|
|
||||||
*/
|
|
||||||
public selfserve_dynamic_image_size_c $dynamic_image_size;
|
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
@@ -43,12 +36,10 @@ class selfserve_c
|
|||||||
$this->allowUpdate([
|
$this->allowUpdate([
|
||||||
selfserve_enabled_c::class,
|
selfserve_enabled_c::class,
|
||||||
selfserve_minute_product_c::class,
|
selfserve_minute_product_c::class,
|
||||||
selfserve_machine_wash_minutes_included_c::class,
|
selfserve_machine_wash_minutes_included_c::class
|
||||||
selfserve_dynamic_image_size_c::class
|
|
||||||
]);
|
]);
|
||||||
$this->enabled = new selfserve_enabled_c();
|
$this->enabled = new selfserve_enabled_c();
|
||||||
$this->minute_product = new selfserve_minute_product_c();
|
$this->minute_product = new selfserve_minute_product_c();
|
||||||
$this->machine_wash_minutes_included = new selfserve_machine_wash_minutes_included_c();
|
$this->machine_wash_minutes_included = new selfserve_machine_wash_minutes_included_c();
|
||||||
$this->dynamic_image_size = new selfserve_dynamic_image_size_c();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,12 +171,13 @@ trait selfserve_lane_command_t
|
|||||||
* Ensure machine relay is ON when a wash starts, when it is allowed by configuration.
|
* Ensure machine relay is ON when a wash starts, when it is allowed by configuration.
|
||||||
* If machine relay is not configured, this is a no-op.
|
* If machine relay is not configured, this is a no-op.
|
||||||
*/
|
*/
|
||||||
protected function setMachineRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
protected function setMachineRelayStatusForWashStart(): void
|
||||||
{
|
{
|
||||||
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE)) {
|
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($this->isMachineWashSelectedAndAvailableForStart($arguments)) {
|
$active_wash = new selfserve_wash_flow();
|
||||||
|
if ($active_wash->isMachineAllowedToStartWash($this->id)) {
|
||||||
try {
|
try {
|
||||||
$this->setMachineRelayStatusHard(true);
|
$this->setMachineRelayStatusHard(true);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
@@ -192,116 +193,6 @@ trait selfserve_lane_command_t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep the program picker relay aligned with the selected wash mode at START.
|
|
||||||
* It is ON only when the customer explicitly selected machine wash.
|
|
||||||
*/
|
|
||||||
protected function setProgramPickerRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
|
||||||
{
|
|
||||||
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$shouldEnable = $this->isExplicitMachineWashModeSelectedForStart($arguments)
|
|
||||||
&& $this->isMachineWashSelectedAndAvailableForStart($arguments);
|
|
||||||
$this->setMachineProgramPickerRelayStatusHard($shouldEnable);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// Best effort only; wash start must continue.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
|
||||||
{
|
|
||||||
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->setMachineProgramPickerRelayStatusHard($this->shouldEnableProgramPickerRelayForWashStart($arguments));
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// Best effort only; wash start must continue.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function isMachineWashSelectedAndAvailableForStart(?selfserve_lane_command_arguments $arguments = null): bool
|
|
||||||
{
|
|
||||||
if (!$this->shouldEnableSelectedMachineServiceForWashStart($arguments)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$reg = method_exists($this, 'getLicensePlate') ? trim((string)$this->getLicensePlate()) : '';
|
|
||||||
if ($reg === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$customerNumber = method_exists($this, 'getCustomerNumber') ? (int)$this->getCustomerNumber() : null;
|
|
||||||
$snapshot = (new selfserve_wash_flow())->previewVehicleEligibility(
|
|
||||||
(int)$this->id,
|
|
||||||
$reg,
|
|
||||||
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null
|
|
||||||
);
|
|
||||||
|
|
||||||
return (bool)($snapshot['allowed'] ?? false);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function shouldEnableSelectedMachineServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): bool
|
|
||||||
{
|
|
||||||
if (!$this->isMachineWashModeSelectedForStart($arguments)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->isMachineServiceSelectedForWashStart();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function shouldEnableProgramPickerRelayForWashStart(?selfserve_lane_command_arguments $arguments = null): bool
|
|
||||||
{
|
|
||||||
return $this->isExplicitMachineWashModeSelectedForStart($arguments)
|
|
||||||
&& $this->isMachineServiceSelectedForWashStart();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function isExplicitMachineWashModeSelectedForStart(?selfserve_lane_command_arguments $arguments = null): bool
|
|
||||||
{
|
|
||||||
return $arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MACHINE;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function isMachineWashModeSelectedForStart(?selfserve_lane_command_arguments $arguments = null): bool
|
|
||||||
{
|
|
||||||
if ($arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MANUAL) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MACHINE) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->isMachineServiceSelectedForWashStart();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function isMachineServiceSelectedForWashStart(): bool
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
if (method_exists($this, 'getLaneCache') && defined(self::class . '::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES')) {
|
|
||||||
$services = $this->getLaneCache((int)$this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES);
|
|
||||||
if (is_array($services)) {
|
|
||||||
foreach ($services as $service) {
|
|
||||||
if (strtoupper((string)$service) === 'MACHINE') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// Fall through to fail-closed when the selected service cache is unavailable.
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function openEntrancePortForWashStart(): void
|
protected function openEntrancePortForWashStart(): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -383,26 +274,30 @@ trait selfserve_lane_command_t
|
|||||||
protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void
|
protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void
|
||||||
{
|
{
|
||||||
if ($arguments->defer_relay_side_effects) {
|
if ($arguments->defer_relay_side_effects) {
|
||||||
$this->setProgramPickerRelayStatusFromSelectedServiceForWashStart($arguments);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure cleaner relay is enabled whenever wash starts.
|
||||||
$this->turnOnCleanerRelayForWashStart();
|
$this->turnOnCleanerRelayForWashStart();
|
||||||
$this->setProgramPickerRelayStatusForWashStart($arguments);
|
// Ensure the machine relay is ON when a wash starts, when it is allowed.
|
||||||
$this->setMachineRelayStatusForWashStart($arguments);
|
$this->setMachineRelayStatusForWashStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function resolveSelfServeActionWashModeForStart(?selfserve_lane_command_arguments $arguments = null): string
|
protected function resolveSelfServeActionWashModeForStart(): string
|
||||||
{
|
{
|
||||||
if ($arguments !== null && in_array($arguments->wash_mode, [
|
try {
|
||||||
selfserve_studio_actions::MODE_MANUAL,
|
if (method_exists($this, 'getLaneCache') && defined(self::class . '::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES')) {
|
||||||
selfserve_studio_actions::MODE_MACHINE,
|
$services = $this->getLaneCache((int)$this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES);
|
||||||
], true)) {
|
if (is_array($services)) {
|
||||||
return $arguments->wash_mode;
|
foreach ($services as $service) {
|
||||||
}
|
if (strtoupper((string)$service) === 'MACHINE') {
|
||||||
|
return selfserve_studio_actions::MODE_MACHINE;
|
||||||
if ($this->isMachineServiceSelectedForWashStart()) {
|
}
|
||||||
return selfserve_studio_actions::MODE_MACHINE;
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Fall through to manual mode when the cached service set is unavailable.
|
||||||
}
|
}
|
||||||
|
|
||||||
return selfserve_studio_actions::MODE_MANUAL;
|
return selfserve_studio_actions::MODE_MANUAL;
|
||||||
@@ -487,16 +382,14 @@ trait selfserve_lane_command_t
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disable relays before opening exit gates in deterministic order:
|
* Disable relays after STOP in deterministic order:
|
||||||
* 1. Cleaner relay
|
* 1. Cleaner relay
|
||||||
* 2. Program picker relay
|
* 2. Machine relay
|
||||||
* 3. Machine relay
|
|
||||||
*/
|
*/
|
||||||
protected function turnOffRelaysAfterStop(): void
|
protected function turnOffRelaysAfterStop(): void
|
||||||
{
|
{
|
||||||
$relays = [
|
$relays = [
|
||||||
selfserve_lane_relay::MACHINE_CLEANER,
|
selfserve_lane_relay::MACHINE_CLEANER,
|
||||||
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
|
|
||||||
selfserve_lane_relay::MACHINE,
|
selfserve_lane_relay::MACHINE,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -538,8 +431,7 @@ trait selfserve_lane_command_t
|
|||||||
$this->id,
|
$this->id,
|
||||||
$this->getLicensePlate() ?: null,
|
$this->getLicensePlate() ?: null,
|
||||||
$this->getCustomerNumber() ?: null,
|
$this->getCustomerNumber() ?: null,
|
||||||
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
|
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null
|
||||||
false
|
|
||||||
);
|
);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
// Session completion must not block STOP flow.
|
// Session completion must not block STOP flow.
|
||||||
@@ -671,17 +563,8 @@ trait selfserve_lane_command_t
|
|||||||
// Set the customer number and license plate
|
// Set the customer number and license plate
|
||||||
$this->setCustomerNumber($customer_number);
|
$this->setCustomerNumber($customer_number);
|
||||||
$this->setLicensePlate($license_plate);
|
$this->setLicensePlate($license_plate);
|
||||||
// Mark the lane occupied before any physical relay or gate side effects.
|
// Mark the lane occupied before any physical entrance relay side effects.
|
||||||
$this->setLaneStatus(selfserve_lane_status::OCCUPIED);
|
$this->setLaneStatus(selfserve_lane_status::OCCUPIED);
|
||||||
$this->runRelaySideEffectsForWashStart($arguments);
|
|
||||||
$this->runPublishedStudioActions(
|
|
||||||
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
|
|
||||||
$this->resolveSelfServeActionWashModeForStart($arguments),
|
|
||||||
[
|
|
||||||
'customer_number' => (int)$customer_number,
|
|
||||||
'reg' => $license_plate,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
// Gateway timeouts are ambiguous because the relay may already have received
|
// Gateway timeouts are ambiguous because the relay may already have received
|
||||||
// the pulse, so openEntrancePortForWashStart() reports them and continues.
|
// the pulse, so openEntrancePortForWashStart() reports them and continues.
|
||||||
@@ -697,6 +580,15 @@ trait selfserve_lane_command_t
|
|||||||
$this->setLaneState(selfserve_lane_state::IN_WASH);
|
$this->setLaneState(selfserve_lane_state::IN_WASH);
|
||||||
// Start the wash timer
|
// Start the wash timer
|
||||||
$this->setWashStartTime(time());
|
$this->setWashStartTime(time());
|
||||||
|
$this->runPublishedStudioActions(
|
||||||
|
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
|
||||||
|
$this->resolveSelfServeActionWashModeForStart(),
|
||||||
|
[
|
||||||
|
'customer_number' => (int)$customer_number,
|
||||||
|
'reg' => $license_plate,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$this->runRelaySideEffectsForWashStart($arguments);
|
||||||
// Log the lane start event
|
// Log the lane start event
|
||||||
$this->logLaneAction(selfserve_lane_log_action::START_WASH);
|
$this->logLaneAction(selfserve_lane_log_action::START_WASH);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -712,8 +604,6 @@ trait selfserve_lane_command_t
|
|||||||
}
|
}
|
||||||
// Snapshot the physical machine ON signal before session completion/reset.
|
// Snapshot the physical machine ON signal before session completion/reset.
|
||||||
$machine_start_triggered = $this->hasMachineStartSignalForStop();
|
$machine_start_triggered = $this->hasMachineStartSignalForStop();
|
||||||
// Turn off relays before any configured or default exit gate opens.
|
|
||||||
$this->turnOffRelaysAfterStop();
|
|
||||||
$this->runPublishedStudioActions(
|
$this->runPublishedStudioActions(
|
||||||
selfserve_studio_actions::EVENT_WASH_STOP_COMMAND,
|
selfserve_studio_actions::EVENT_WASH_STOP_COMMAND,
|
||||||
$machine_start_triggered ? selfserve_studio_actions::MODE_MACHINE : selfserve_studio_actions::MODE_MANUAL,
|
$machine_start_triggered ? selfserve_studio_actions::MODE_MACHINE : selfserve_studio_actions::MODE_MANUAL,
|
||||||
@@ -726,6 +616,8 @@ trait selfserve_lane_command_t
|
|||||||
// Open the exit port. Gateway timeouts are ambiguous because
|
// Open the exit port. Gateway timeouts are ambiguous because
|
||||||
// the relay may already have received the pulse.
|
// the relay may already have received the pulse.
|
||||||
$this->openExitPortForWashStop();
|
$this->openExitPortForWashStop();
|
||||||
|
// Turn off relays in deterministic order after STOP
|
||||||
|
$this->turnOffRelaysAfterStop();
|
||||||
// Log the lane stop event
|
// Log the lane stop event
|
||||||
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
|
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
|
||||||
// Invoice the customer
|
// Invoice the customer
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ use modules\selfserve\helpers\selfserve_lane_relay;
|
|||||||
use modules\selfserve\helpers\selfserve_lane_services;
|
use modules\selfserve\helpers\selfserve_lane_services;
|
||||||
use modules\selfserve\helpers\selfserve_lane_status;
|
use modules\selfserve\helpers\selfserve_lane_status;
|
||||||
use modules\shelly\helpers\shelly_request_body_get_states;
|
use modules\shelly\helpers\shelly_request_body_get_states;
|
||||||
use objects\selfserve_wash_sessions_o;
|
|
||||||
|
|
||||||
trait selfserve_lane_relay_controller_t
|
trait selfserve_lane_relay_controller_t
|
||||||
{
|
{
|
||||||
@@ -918,38 +917,7 @@ trait selfserve_lane_relay_controller_t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $this->sendRelaySwitchCommand($relay, true, $duration);
|
return $this->sendRelaySwitchCommand($relay, true, $duration);
|
||||||
if ($result && $relay === selfserve_lane_relay::MACHINE) {
|
|
||||||
$this->markLatestSelfServeSessionRelayEnabledForLane();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function markLatestSelfServeSessionRelayEnabledForLane(): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$customerNumber = (int)$this->getCustomerNumber();
|
|
||||||
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane(
|
|
||||||
(int)$this->id,
|
|
||||||
$customerNumber > 0 ? $customerNumber : null
|
|
||||||
);
|
|
||||||
if (!$session->exists() && $customerNumber > 0) {
|
|
||||||
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane((int)$this->id);
|
|
||||||
}
|
|
||||||
if (!$session->exists() || (bool)$session->machine_relay_enabled->value() === true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$session->markRelayEnabled();
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
error_log(
|
|
||||||
'Failed to synchronize self-serve machine relay session state for lane '
|
|
||||||
. (int)$this->id
|
|
||||||
. ': '
|
|
||||||
. $e->getMessage()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace slack\config;
|
|
||||||
|
|
||||||
use Exception;
|
|
||||||
use traits\module_config_variable;
|
|
||||||
|
|
||||||
class slack_customer_registration_webhook_url_c
|
|
||||||
{
|
|
||||||
use module_config_variable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
self::setupConfigVariable(
|
|
||||||
'Slack',
|
|
||||||
'customer_registration_webhook_url',
|
|
||||||
'string',
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
'Slack webhook URL used for successful customer registration notifications',
|
|
||||||
'https://hooks.slack.com/services/...',
|
|
||||||
true,
|
|
||||||
''
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace slack;
|
|
||||||
|
|
||||||
require_once WD . '/modules/slack/config/slack_customer_registration_webhook_url_c.php';
|
|
||||||
|
|
||||||
use slack\config\slack_customer_registration_webhook_url_c;
|
|
||||||
use traits\module_config_t;
|
|
||||||
|
|
||||||
class slack_c
|
|
||||||
{
|
|
||||||
use module_config_t;
|
|
||||||
|
|
||||||
public slack_customer_registration_webhook_url_c $customer_registration_webhook_url;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->setupConfig('Slack');
|
|
||||||
$this->allowUpdate([
|
|
||||||
slack_customer_registration_webhook_url_c::class,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->customer_registration_webhook_url = new slack_customer_registration_webhook_url_c();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -606,76 +606,6 @@ class collected_order_invoices_o extends db
|
|||||||
self::deleteCached('asArray', $this->id);
|
self::deleteCached('asArray', $this->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Move this invoice collection and all attached orders to another customer.
|
|
||||||
*
|
|
||||||
* @return array<string,mixed>
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
public function moveToCustomer(int $target_customer_number): array
|
|
||||||
{
|
|
||||||
global $db;
|
|
||||||
|
|
||||||
self::requireSelected();
|
|
||||||
self::requireValidCustomer((string)$target_customer_number);
|
|
||||||
|
|
||||||
if ($target_customer_number <= 0) {
|
|
||||||
throw new Exception('Target customer number must be greater than zero');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($this->external_id->value()) || $this->booked_invoice_id->value() !== null) {
|
|
||||||
throw new Exception('Invoice collections with an external or booked invoice cannot be moved');
|
|
||||||
}
|
|
||||||
|
|
||||||
$source_customer_number = (int)$this->customer_number->value();
|
|
||||||
if ($source_customer_number === $target_customer_number) {
|
|
||||||
return [
|
|
||||||
'invoice_collection_id' => (int)$this->id,
|
|
||||||
'source_customer_number' => $source_customer_number,
|
|
||||||
'target_customer_number' => $target_customer_number,
|
|
||||||
'moved_order_ids' => [],
|
|
||||||
'moved_order_count' => 0,
|
|
||||||
'changed' => false,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$invoice_collection_id = (int)$this->id;
|
|
||||||
$result = $db->query("SELECT id FROM orders WHERE invoice_collection_id = {$invoice_collection_id}");
|
|
||||||
$order_ids = array_map(
|
|
||||||
static fn(array $row): int => (int)$row['id'],
|
|
||||||
$db->fetch_all($result)
|
|
||||||
);
|
|
||||||
|
|
||||||
$db->conn()->begin_transaction();
|
|
||||||
try {
|
|
||||||
$this->customer_number->set($target_customer_number);
|
|
||||||
|
|
||||||
foreach ( $order_ids as $order_id ) {
|
|
||||||
$order = (new orders_o())->select($order_id);
|
|
||||||
if (!$order->exists()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$order->customer_id->set($target_customer_number);
|
|
||||||
$order->objectChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->objectChanged();
|
|
||||||
$db->conn()->commit();
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$db->conn()->rollback();
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'invoice_collection_id' => $invoice_collection_id,
|
|
||||||
'source_customer_number' => $source_customer_number,
|
|
||||||
'target_customer_number' => $target_customer_number,
|
|
||||||
'moved_order_ids' => $order_ids,
|
|
||||||
'moved_order_count' => count($order_ids),
|
|
||||||
'changed' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the external id of the invoice collection
|
* Get the external id of the invoice collection
|
||||||
* @throws Exception If the request was not successful
|
* @throws Exception If the request was not successful
|
||||||
|
|||||||
@@ -18,18 +18,13 @@ class customer_password_reset_keys_o extends db
|
|||||||
public object_property $updated_at;
|
public object_property $updated_at;
|
||||||
public object_property $deleted_at;
|
public object_property $deleted_at;
|
||||||
const TOKEN_LENGTH = 32;
|
const TOKEN_LENGTH = 32;
|
||||||
const TOKEN_EXPIRY_SECONDS = 72 * 60 * 60; // 72 hours
|
const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
|
||||||
|
|
||||||
public function structure(): void
|
public function structure(): void
|
||||||
{
|
{
|
||||||
$this->setTable('customer_password_reset_keys');
|
$this->setTable('customer_password_reset_keys');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validTokenWhereClause(): string
|
|
||||||
{
|
|
||||||
return "deleted_at IS NULL AND created_at >= DATE_SUB(NOW(), INTERVAL " . self::TOKEN_EXPIRY_SECONDS . " SECOND)";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new customer reset key
|
* Add a new customer reset key
|
||||||
@@ -70,8 +65,9 @@ class customer_password_reset_keys_o extends db
|
|||||||
if (strlen($token) !== self::TOKEN_LENGTH) {
|
if (strlen($token) !== self::TOKEN_LENGTH) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Query the database for a valid token using the same clock that writes created_at.
|
// Query the database for a valid token
|
||||||
$sql = "SELECT id FROM $this->table WHERE token = '" . $db->escape_string($token) . "' AND " . $this->validTokenWhereClause() . " LIMIT 1";
|
$current_time = date('Y-m-d H:i:s');
|
||||||
|
$sql = "SELECT id FROM $this->table WHERE token = '" . $db->escape_string($token) . "' AND deleted_at IS NULL AND created_at >= DATE_SUB('$current_time', INTERVAL " . self::TOKEN_EXPIRY_SECONDS . " SECOND) LIMIT 1";
|
||||||
$result = $db->query($sql);
|
$result = $db->query($sql);
|
||||||
if ($result->num_rows === 0) {
|
if ($result->num_rows === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -89,11 +85,13 @@ class customer_password_reset_keys_o extends db
|
|||||||
*/
|
*/
|
||||||
public function isValidToken(): bool
|
public function isValidToken(): bool
|
||||||
{
|
{
|
||||||
global $db;
|
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
$sql = "SELECT id FROM $this->table WHERE id = " . (int)$this->id . " AND " . $this->validTokenWhereClause() . " LIMIT 1";
|
$created_at = strtotime($this->created_at->value());
|
||||||
$result = $db->query($sql);
|
$current_time = time();
|
||||||
return $result->num_rows > 0;
|
return (
|
||||||
|
($current_time - $created_at) <= self::TOKEN_EXPIRY_SECONDS) &&
|
||||||
|
($this->deleted_at->value() === null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,4 +140,4 @@ class customer_password_reset_keys_o extends db
|
|||||||
{
|
{
|
||||||
//TODO: Add cache invalidation
|
//TODO: Add cache invalidation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,6 @@ use classes\object_property;
|
|||||||
use classes\selfserve;
|
use classes\selfserve;
|
||||||
use classes\selfserve_schema_bootstrap;
|
use classes\selfserve_schema_bootstrap;
|
||||||
use Exception;
|
use Exception;
|
||||||
use modules\selfserve\classes\selfserve_config_versioning;
|
|
||||||
use traits\db_object_t;
|
use traits\db_object_t;
|
||||||
|
|
||||||
class department_lanes_o extends db
|
class department_lanes_o extends db
|
||||||
@@ -325,52 +324,9 @@ class department_lanes_o extends db
|
|||||||
public function getSelfServeLaneProducts(): array
|
public function getSelfServeLaneProducts(): array
|
||||||
{
|
{
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
$publishedProducts = $this->getPublishedSelfServeLaneProducts();
|
|
||||||
if ($publishedProducts !== []) {
|
|
||||||
return $publishedProducts;
|
|
||||||
}
|
|
||||||
|
|
||||||
return department_selfserve_tasks_o::getLaneProducts((int)$this->id);
|
return department_selfserve_tasks_o::getLaneProducts((int)$this->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return int[]
|
|
||||||
*/
|
|
||||||
private function getPublishedSelfServeLaneProducts(): array
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$published = (new selfserve_config_versioning())->getPublishedV2Config((int)$this->department->value());
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$config = is_array($published['config'] ?? null) ? $published['config'] : null;
|
|
||||||
if ($config === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$laneId = (int)$this->id;
|
|
||||||
$products = [];
|
|
||||||
foreach ((array)($config['tasks'] ?? []) as $task) {
|
|
||||||
if (!is_array($task)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$taskLane = (int)($task['lane'] ?? 0);
|
|
||||||
if ($taskLane !== 0 && $taskLane !== $laneId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$productId = (int)($task['product'] ?? 0);
|
|
||||||
if ($productId > 0 && !in_array($productId, $products, true)) {
|
|
||||||
$products[] = $productId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sort($products, SORT_NUMERIC);
|
|
||||||
return $products;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
* @return department_lanes_o[] An array of department lane objects for the specified department
|
* @return department_lanes_o[] An array of department lane objects for the specified department
|
||||||
|
|||||||
@@ -158,18 +158,6 @@ class selfserve_wash_sessions_o extends db
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function markCompleted(?int $orderId = null): void
|
public function markCompleted(?int $orderId = null): void
|
||||||
{
|
|
||||||
if (!$this->closeIfOpen(selfserve_wash_session_status::COMPLETED, $orderId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function markCompletedIfOpen(?int $orderId = null): bool
|
|
||||||
{
|
|
||||||
return $this->closeIfOpen(selfserve_wash_session_status::COMPLETED, $orderId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function markCompletedInMemory(?int $orderId = null): void
|
|
||||||
{
|
{
|
||||||
$this->completed_at->set(date('Y-m-d H:i:s'));
|
$this->completed_at->set(date('Y-m-d H:i:s'));
|
||||||
if ($orderId !== null) {
|
if ($orderId !== null) {
|
||||||
@@ -179,18 +167,6 @@ class selfserve_wash_sessions_o extends db
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function markForceStopped(?int $orderId = null, ?array $metadata = null): void
|
public function markForceStopped(?int $orderId = null, ?array $metadata = null): void
|
||||||
{
|
|
||||||
if (!$this->closeIfOpen(selfserve_wash_session_status::FORCE_STOPPED, $orderId, $metadata)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function markForceStoppedIfOpen(?int $orderId = null, ?array $metadata = null): bool
|
|
||||||
{
|
|
||||||
return $this->closeIfOpen(selfserve_wash_session_status::FORCE_STOPPED, $orderId, $metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function markForceStoppedInMemory(?int $orderId = null, ?array $metadata = null): void
|
|
||||||
{
|
{
|
||||||
$this->completed_at->set(date('Y-m-d H:i:s'));
|
$this->completed_at->set(date('Y-m-d H:i:s'));
|
||||||
if ($orderId !== null) {
|
if ($orderId !== null) {
|
||||||
@@ -205,53 +181,6 @@ class selfserve_wash_sessions_o extends db
|
|||||||
$this->status->set(selfserve_wash_session_status::FORCE_STOPPED->value);
|
$this->status->set(selfserve_wash_session_status::FORCE_STOPPED->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function closeIfOpen(selfserve_wash_session_status $status, ?int $orderId = null, ?array $metadata = null): bool
|
|
||||||
{
|
|
||||||
if (!$this->isPersistedSession()) {
|
|
||||||
if ($status === selfserve_wash_session_status::COMPLETED) {
|
|
||||||
$this->markCompletedInMemory($orderId);
|
|
||||||
} else {
|
|
||||||
$this->markForceStoppedInMemory($orderId, $metadata);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
global $db;
|
|
||||||
|
|
||||||
$updates = [
|
|
||||||
"`completed_at` = NOW()",
|
|
||||||
"`status` = '" . $db->escape_string($status->value) . "'",
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($orderId !== null) {
|
|
||||||
$updates[] = "`order_id` = " . (int)$orderId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($metadata !== null) {
|
|
||||||
$existing = $this->metadata_json->value();
|
|
||||||
$existing = is_array($existing) ? $existing : [];
|
|
||||||
$existing['force_stop'] = $metadata;
|
|
||||||
$updates[] = "`metadata_json` = '" . $db->escape_string(json_encode($existing, JSON_THROW_ON_ERROR)) . "'";
|
|
||||||
}
|
|
||||||
|
|
||||||
$terminalStatuses = self::terminalStatusSqlList();
|
|
||||||
$db->query(
|
|
||||||
"UPDATE `selfserve_wash_sessions` SET " . implode(', ', $updates) .
|
|
||||||
" WHERE `id` = " . (int)$this->id .
|
|
||||||
" AND `completed_at` IS NULL" .
|
|
||||||
" AND UPPER(TRIM(`status`)) NOT IN ($terminalStatuses)"
|
|
||||||
);
|
|
||||||
|
|
||||||
$changed = $db->conn()->affected_rows > 0;
|
|
||||||
$this->select((int)$this->id);
|
|
||||||
return $changed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isPersistedSession(): bool
|
|
||||||
{
|
|
||||||
return isset($this->id) && (int)$this->id > 0 && $this->exists();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function selectLatestOpenByLane(int $laneId, ?int $customerNumber = null): self
|
public function selectLatestOpenByLane(int $laneId, ?int $customerNumber = null): self
|
||||||
{
|
{
|
||||||
$filters = [
|
$filters = [
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ class users_o extends db
|
|||||||
{
|
{
|
||||||
use db_object_t;
|
use db_object_t;
|
||||||
|
|
||||||
public const KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS = 'superuser_new_customer_email_notifications_enabled';
|
|
||||||
|
|
||||||
public object_property $customer_number;
|
public object_property $customer_number;
|
||||||
public object_property $display_name;
|
public object_property $display_name;
|
||||||
public object_property $group_id;
|
public object_property $group_id;
|
||||||
@@ -127,47 +125,40 @@ class users_o extends db
|
|||||||
|
|
||||||
private function importCustomerFromExternalSource(int $customer_number): object|bool
|
private function importCustomerFromExternalSource(int $customer_number): object|bool
|
||||||
{
|
{
|
||||||
|
global $db;
|
||||||
// Get the customer data from the external source
|
// Get the customer data from the external source
|
||||||
$economic = new economicCustomers();
|
$economic = new economicCustomers();
|
||||||
$customer_data = $economic->getCustomerId($customer_number);
|
$customer_data = $economic->getCustomerId($customer_number);
|
||||||
|
// DEBUG: Return the customer data
|
||||||
|
// Check if the customer exists
|
||||||
if ($customer_data) {
|
if ($customer_data) {
|
||||||
return $this->importCustomerFromEconomicCustomerData($customer_data);
|
// Avoid SQL injection
|
||||||
|
$customer_number = $db->escape_string($customer_data->customerNumber);
|
||||||
|
// Double check if the customer exists
|
||||||
|
$sql = "SELECT * FROM $this->table WHERE customer_number = '$customer_number'";
|
||||||
|
$result = $db->query($sql);
|
||||||
|
if ($result->num_rows > 0) {
|
||||||
|
$this->id = $result->fetch_assoc()['id'];
|
||||||
|
$this->getObjectProperties();
|
||||||
|
} else {
|
||||||
|
// Import the customer
|
||||||
|
$this->add($customer_number, '', 0);
|
||||||
|
// Nullify the password
|
||||||
|
$this->password->nullify();
|
||||||
|
// If the customer has an email address, save it
|
||||||
|
if (isset($customer_data->email)) {
|
||||||
|
$this->email->set($customer_data->email);
|
||||||
|
}
|
||||||
|
// If the customer has a name, save it as the display name
|
||||||
|
if (isset($customer_data->name)) {
|
||||||
|
$this->display_name->set($customer_data->name);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Else return false
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function importCustomerFromEconomicCustomerData(object $customer_data): users_o|bool
|
|
||||||
{
|
|
||||||
global $db;
|
|
||||||
|
|
||||||
if (!isset($customer_data->customerNumber) || !is_numeric($customer_data->customerNumber)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$customer_number = $db->escape_string((string)$customer_data->customerNumber);
|
|
||||||
$sql = "SELECT * FROM $this->table WHERE customer_number = '$customer_number'";
|
|
||||||
$result = $db->query($sql);
|
|
||||||
if ($result->num_rows > 0) {
|
|
||||||
$this->id = (int)$result->fetch_assoc()['id'];
|
|
||||||
$this->getObjectProperties();
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->add($customer_number, '', 0);
|
|
||||||
$this->password->nullify();
|
|
||||||
|
|
||||||
if (isset($customer_data->email)) {
|
|
||||||
$this->email->set($customer_data->email);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($customer_data->name)) {
|
|
||||||
$this->display_name->set($customer_data->name);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
@@ -265,7 +256,7 @@ class users_o extends db
|
|||||||
* @param int|null $user_id The user id to add the attribute to
|
* @param int|null $user_id The user id to add the attribute to
|
||||||
* @throws Exception If the user is not selected, and the user_id is null
|
* @throws Exception If the user is not selected, and the user_id is null
|
||||||
*/
|
*/
|
||||||
public function addAttribute(string $attribute, ?int $user_id = null): void
|
public function addAttribute(string $attribute, int $user_id = null): void
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
if ($user_id === null) {
|
if ($user_id === null) {
|
||||||
@@ -279,7 +270,7 @@ class users_o extends db
|
|||||||
$db->query($sql);
|
$db->query($sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteAttribute(string $attribute, ?int $user_id = null): void
|
public function deleteAttribute(string $attribute, int $user_id = null): void
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
if ($user_id === null) {
|
if ($user_id === null) {
|
||||||
@@ -311,7 +302,7 @@ class users_o extends db
|
|||||||
$db->query($sql);
|
$db->query($sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function doesUserHaveAttribute(string $attribute, ?int $user_id = null): bool
|
public function doesUserHaveAttribute(string $attribute, int $user_id = null): bool
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
if ($user_id === null) {
|
if ($user_id === null) {
|
||||||
@@ -445,7 +436,6 @@ class users_o extends db
|
|||||||
'sms_notifications_enabled' => (bool)$this->sms_notifications_enabled->value(),
|
'sms_notifications_enabled' => (bool)$this->sms_notifications_enabled->value(),
|
||||||
'email_notifications_enabled' => (bool)$this->email_notifications_enabled->value(),
|
'email_notifications_enabled' => (bool)$this->email_notifications_enabled->value(),
|
||||||
'wash_certificate_email' => $this->wash_certificate_email->value(),
|
'wash_certificate_email' => $this->wash_certificate_email->value(),
|
||||||
self::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS => $this->isSuperuserNewCustomerEmailNotificationsEnabled(),
|
|
||||||
],
|
],
|
||||||
'created_at' => $this->created_at->value(),
|
'created_at' => $this->created_at->value(),
|
||||||
'updated_at' => $this->updated_at->value(),
|
'updated_at' => $this->updated_at->value(),
|
||||||
@@ -525,12 +515,8 @@ class users_o extends db
|
|||||||
return customer_name_cache_payload_builder::build($cached_name, $fallback_name);
|
return customer_name_cache_payload_builder::build($cached_name, $fallback_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCustomerEcocomicData(?int $customer_number = null): users_o
|
public function getCustomerEcocomicData(int $customer_number = null): users_o
|
||||||
{
|
{
|
||||||
if ($customer_number !== null && !isset($this->id)) {
|
|
||||||
$this->getUserByCustomerNumber($customer_number);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the customer number is set
|
// Check if the customer number is set
|
||||||
if (!isset($this->customer_number) && $customer_number === null) {
|
if (!isset($this->customer_number) && $customer_number === null) {
|
||||||
return $this;
|
return $this;
|
||||||
@@ -542,9 +528,7 @@ class users_o extends db
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
$cachedCustomer = isset($this->id) && $this->id > 0
|
$cachedCustomer = $this->getCached('economic_customer');
|
||||||
? $this->getCached('economic_customer')
|
|
||||||
: null;
|
|
||||||
if (is_object($cachedCustomer)) {
|
if (is_object($cachedCustomer)) {
|
||||||
$cachedCustomerNumber = (int)($cachedCustomer->customerNumber ?? $cachedCustomer->customer_number ?? 0);
|
$cachedCustomerNumber = (int)($cachedCustomer->customerNumber ?? $cachedCustomer->customer_number ?? 0);
|
||||||
if ($cachedCustomerNumber === $customer_number) {
|
if ($cachedCustomerNumber === $customer_number) {
|
||||||
@@ -730,7 +714,7 @@ class users_o extends db
|
|||||||
$this->permissions = $perms;
|
$this->permissions = $perms;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getUserAttributes(?int $user_id = null): array
|
public function getUserAttributes(int $user_id = null): array
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
if ($user_id === null) {
|
if ($user_id === null) {
|
||||||
@@ -847,53 +831,6 @@ class users_o extends db
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isSuperuserNewCustomerEmailNotificationsEnabled(): bool
|
|
||||||
{
|
|
||||||
self::requireSelected();
|
|
||||||
$value = $this->keys->setUser($this->id)->getValue(self::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS);
|
|
||||||
return in_array(strtolower((string)$value), ['1', 'true', 'yes', 'on'], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setSuperuserNewCustomerEmailNotificationsEnabled(bool $enabled): void
|
|
||||||
{
|
|
||||||
self::requireSelected();
|
|
||||||
$this->keys->setUser($this->id)->setValue(
|
|
||||||
self::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS,
|
|
||||||
$enabled ? '1' : '0'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array{id:int, customer_number:int, display_name:string|null, email:string}>
|
|
||||||
*/
|
|
||||||
public function getSuperuserNewCustomerEmailNotificationRecipients(): array
|
|
||||||
{
|
|
||||||
global $db;
|
|
||||||
|
|
||||||
$key = $db->escape_string(self::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS);
|
|
||||||
$sql = "
|
|
||||||
SELECT DISTINCT
|
|
||||||
u.id,
|
|
||||||
u.customer_number,
|
|
||||||
u.display_name,
|
|
||||||
u.email
|
|
||||||
FROM users u
|
|
||||||
INNER JOIN user_key_value_pairs kv
|
|
||||||
ON kv.user_id = u.id
|
|
||||||
AND kv.var = '$key'
|
|
||||||
AND LOWER(kv.val) IN ('1', 'true', 'yes', 'on')
|
|
||||||
LEFT JOIN groups_permissions gp
|
|
||||||
ON gp.group_id = u.group_id
|
|
||||||
AND gp.permission = 'superuser'
|
|
||||||
WHERE u.deleted_at IS NULL
|
|
||||||
AND u.email IS NOT NULL
|
|
||||||
AND u.email <> ''
|
|
||||||
AND (u.group_id = 1 OR gp.id IS NOT NULL)
|
|
||||||
";
|
|
||||||
|
|
||||||
return $db->fetch_all($db->query($sql));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setOpenInvoiceDraft(int $draftInvoiceNumber): void
|
public function setOpenInvoiceDraft(int $draftInvoiceNumber): void
|
||||||
{
|
{
|
||||||
// Set the open invoice draft (key = 'open_invoice_draft')
|
// Set the open invoice draft (key = 'open_invoice_draft')
|
||||||
@@ -1119,7 +1056,7 @@ class users_o extends db
|
|||||||
* Set the password for the user
|
* Set the password for the user
|
||||||
* @throws Exception If the user is not selected
|
* @throws Exception If the user is not selected
|
||||||
*/
|
*/
|
||||||
public function setPassword(?string $password = null): void
|
public function setPassword(string $password = null): void
|
||||||
{
|
{
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
global $db;
|
global $db;
|
||||||
|
|||||||
+165
-120
@@ -3043,7 +3043,6 @@ paths:
|
|||||||
wash_certificate_email: {type: string}
|
wash_certificate_email: {type: string}
|
||||||
sms_notifications_enabled: {type: boolean}
|
sms_notifications_enabled: {type: boolean}
|
||||||
email_notifications_enabled: {type: boolean}
|
email_notifications_enabled: {type: boolean}
|
||||||
superuser_new_customer_email_notifications_enabled: {type: boolean}
|
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Success
|
description: Success
|
||||||
@@ -6266,33 +6265,6 @@ paths:
|
|||||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
'403': { $ref: '#/components/responses/Forbidden' }
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
|
||||||
/order-bookings/completion-confirmation/resend:
|
|
||||||
post:
|
|
||||||
tags:
|
|
||||||
- Bookings
|
|
||||||
summary: Resend order booking completion confirmation
|
|
||||||
description: Resends the customer completion confirmation email with the wash certificate for a completed order booking. Requires `complete_bookings` and access to the booking's department.
|
|
||||||
operationId: resendOrderBookingCompletionConfirmation
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required: [id]
|
|
||||||
properties:
|
|
||||||
id: {type: integer}
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Completion confirmation resent successfully
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: {}
|
|
||||||
'400': { $ref: '#/components/responses/BadRequest' }
|
|
||||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
|
||||||
'403': { $ref: '#/components/responses/Forbidden' }
|
|
||||||
'409': { $ref: '#/components/responses/Conflict' }
|
|
||||||
|
|
||||||
/order-bookings/complete:
|
/order-bookings/complete:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@@ -9448,14 +9420,6 @@ paths:
|
|||||||
license_plate:
|
license_plate:
|
||||||
type: string
|
type: string
|
||||||
description: Required for START command
|
description: Required for START command
|
||||||
wash_type:
|
|
||||||
type: string
|
|
||||||
enum: [Manual, Machine]
|
|
||||||
description: Optional customer-selected wash type for START. When provided, Manual and Machine start actions use this explicit choice instead of inferring mode from allowed services.
|
|
||||||
wash_mode:
|
|
||||||
type: string
|
|
||||||
enum: [manual, machine]
|
|
||||||
description: Lowercase alias for wash_type accepted by backend clients.
|
|
||||||
customer_number:
|
customer_number:
|
||||||
type: integer
|
type: integer
|
||||||
description: Required for START and RESERVE commands. The authenticated customer's number is applied server-side when omitted by user clients.
|
description: Required for START and RESERVE commands. The authenticated customer's number is applied server-side when omitted by user clients.
|
||||||
@@ -11253,6 +11217,46 @@ paths:
|
|||||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
'403': { $ref: '#/components/responses/Forbidden' }
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
|
||||||
|
|
||||||
|
/superuser/coolify/github-runners:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Superuser
|
||||||
|
summary: Coolify-managed GitHub Actions runner state
|
||||||
|
operationId: getSuperuserCoolifyGithubRunners
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: GitHub runner summary returned successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SuperuserCoolifyGithubRunnerResponse'
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
|
||||||
|
/superuser/coolify/github-runners/deploy:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Superuser
|
||||||
|
summary: Deploy Coolify-managed GitHub Actions runners
|
||||||
|
operationId: deploySuperuserCoolifyGithubRunners
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SuperuserCoolifyGithubRunnerDeployRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: GitHub runner deployment result returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SuperuserCoolifyGithubRunnerDeployResponse'
|
||||||
|
'409': { $ref: '#/components/responses/Conflict' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
|
||||||
/superuser/coolify/gateways:
|
/superuser/coolify/gateways:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -11636,52 +11640,6 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ModuleConfigTestResponse'
|
$ref: '#/components/schemas/ModuleConfigTestResponse'
|
||||||
|
|
||||||
/slack/config:
|
|
||||||
get:
|
|
||||||
tags: [Config]
|
|
||||||
summary: Get Slack config
|
|
||||||
operationId: getSlackConfig
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Slack configuration retrieved successfully
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/SlackConfigListResponse'
|
|
||||||
post:
|
|
||||||
tags: [Config]
|
|
||||||
summary: Update Slack config
|
|
||||||
operationId: updateSlackConfig
|
|
||||||
requestBody:
|
|
||||||
required: false
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: {}
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Slack configuration updated successfully
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ModuleConfigUpdateResponse'
|
|
||||||
|
|
||||||
/slack/config/test:
|
|
||||||
post:
|
|
||||||
tags: [Config]
|
|
||||||
summary: Test Slack customer registration webhook
|
|
||||||
operationId: testSlackCustomerRegistrationWebhook
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Slack customer registration webhook test completed successfully
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/SlackConfigTestResponse'
|
|
||||||
'400':
|
|
||||||
description: Slack customer registration webhook URL is not configured
|
|
||||||
'502':
|
|
||||||
description: Slack customer registration webhook test failed
|
|
||||||
|
|
||||||
/backups/config:
|
/backups/config:
|
||||||
get:
|
get:
|
||||||
tags: [Config]
|
tags: [Config]
|
||||||
@@ -13594,6 +13552,8 @@ components:
|
|||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
load_balancer:
|
load_balancer:
|
||||||
$ref: '#/components/schemas/SuperuserCoolifyLoadBalancer'
|
$ref: '#/components/schemas/SuperuserCoolifyLoadBalancer'
|
||||||
|
github_runners:
|
||||||
|
$ref: '#/components/schemas/SuperuserCoolifyGithubRunnerSummary'
|
||||||
meta:
|
meta:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
@@ -13646,6 +13606,126 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
|
||||||
|
|
||||||
|
SuperuserCoolifyGithubRunnerResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/SuperuserCoolifyGithubRunnerSummary'
|
||||||
|
meta:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
includes:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
SuperuserCoolifyGithubRunnerDeployRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
dry_run:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
enforce:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
instance_id:
|
||||||
|
type: integer
|
||||||
|
service_uuid:
|
||||||
|
type: string
|
||||||
|
service_name:
|
||||||
|
type: string
|
||||||
|
frontend_repository:
|
||||||
|
type: string
|
||||||
|
example: copenhagentruckwash/pleno-vue
|
||||||
|
backend_repository:
|
||||||
|
type: string
|
||||||
|
example: copenhagentruckwash/api
|
||||||
|
labels:
|
||||||
|
type: string
|
||||||
|
example: self-hosted,Linux,X64,default
|
||||||
|
count_per_repo:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 10
|
||||||
|
default: 1
|
||||||
|
github_token:
|
||||||
|
type: string
|
||||||
|
writeOnly: true
|
||||||
|
persist_token:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
project_uuid:
|
||||||
|
type: string
|
||||||
|
environment_name:
|
||||||
|
type: string
|
||||||
|
environment_uuid:
|
||||||
|
type: string
|
||||||
|
server_uuid:
|
||||||
|
type: string
|
||||||
|
destination_uuid:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
SuperuserCoolifyGithubRunnerDeployResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
meta:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
includes:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
|
||||||
|
SuperuserCoolifyGithubRunnerSummary:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [not_configured, configured, ok, unknown]
|
||||||
|
config:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
configured:
|
||||||
|
type: boolean
|
||||||
|
service_uuid:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
service_name:
|
||||||
|
type: string
|
||||||
|
repositories:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
labels:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
count_per_repo:
|
||||||
|
type: integer
|
||||||
|
desired_runner_count:
|
||||||
|
type: integer
|
||||||
|
compose_hash:
|
||||||
|
type: string
|
||||||
|
token_set:
|
||||||
|
type: boolean
|
||||||
|
token_source:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [env, config]
|
||||||
|
service:
|
||||||
|
type: object
|
||||||
|
nullable: true
|
||||||
|
additionalProperties: true
|
||||||
|
last_error:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
|
||||||
SuperuserCoolifyLoadBalancerResponse:
|
SuperuserCoolifyLoadBalancerResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -15196,25 +15276,6 @@ components:
|
|||||||
- type: integer
|
- type: integer
|
||||||
required: [module, variable, type, value]
|
required: [module, variable, type, value]
|
||||||
|
|
||||||
SlackConfigEntry:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
module: { type: string, enum: [Slack] }
|
|
||||||
variable: { type: string, enum: [customer_registration_webhook_url] }
|
|
||||||
type: { type: string, enum: [string] }
|
|
||||||
value:
|
|
||||||
type: string
|
|
||||||
example: https://hooks.slack.com/services/...
|
|
||||||
required: [module, variable, type, value]
|
|
||||||
|
|
||||||
SlackConfigTestResult:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
configured: { type: boolean }
|
|
||||||
sent: { type: boolean }
|
|
||||||
message: { type: string }
|
|
||||||
required: [configured, sent, message]
|
|
||||||
|
|
||||||
BackupsConfigEntry:
|
BackupsConfigEntry:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -15483,22 +15544,6 @@ components:
|
|||||||
data: { type: array, items: { $ref: '#/components/schemas/EmailConfigEntry' } }
|
data: { type: array, items: { $ref: '#/components/schemas/EmailConfigEntry' } }
|
||||||
required: [data]
|
required: [data]
|
||||||
|
|
||||||
SlackConfigListResponse:
|
|
||||||
allOf:
|
|
||||||
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
|
||||||
- type: object
|
|
||||||
properties:
|
|
||||||
data: { type: array, items: { $ref: '#/components/schemas/SlackConfigEntry' } }
|
|
||||||
required: [data]
|
|
||||||
|
|
||||||
SlackConfigTestResponse:
|
|
||||||
allOf:
|
|
||||||
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
|
||||||
- type: object
|
|
||||||
properties:
|
|
||||||
data: { $ref: '#/components/schemas/SlackConfigTestResult' }
|
|
||||||
required: [data]
|
|
||||||
|
|
||||||
BackupsConfigListResponse:
|
BackupsConfigListResponse:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
|
||||||
@@ -18474,10 +18519,10 @@ components:
|
|||||||
path_sample_limit:
|
path_sample_limit:
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 1
|
minimum: 1
|
||||||
maximum: 2048
|
maximum: 200
|
||||||
default: 2048
|
default: 200
|
||||||
nullable: true
|
nullable: true
|
||||||
description: Optional cap for returned path rows. Omitted returns every projected terminal path within the state cap; larger values are capped at 2048.
|
description: Optional cap for returned path rows. Omitted and larger values are capped at 200.
|
||||||
|
|
||||||
SelfserveStudioPathOutcomesResponse:
|
SelfserveStudioPathOutcomesResponse:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
parameters:
|
|
||||||
level: 0
|
|
||||||
paths:
|
|
||||||
- classes
|
|
||||||
- interfaces
|
|
||||||
- traits
|
|
||||||
- objects
|
|
||||||
- modules
|
|
||||||
- routes
|
|
||||||
- statistics
|
|
||||||
- tests/Unit
|
|
||||||
- tests/Integration
|
|
||||||
- tests/Api
|
|
||||||
bootstrapFiles:
|
|
||||||
- vendor/autoload.php
|
|
||||||
tmpDir: build/phpstan
|
|
||||||
excludePaths:
|
|
||||||
analyse:
|
|
||||||
- vendor
|
|
||||||
- build
|
|
||||||
- .phpunit.cache
|
|
||||||
- modules/*/vendor
|
|
||||||
- modules/*/vendor/*
|
|
||||||
- tests/Legacy
|
|
||||||
reportUnmatchedIgnoredErrors: false
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Rector\Config\RectorConfig;
|
|
||||||
|
|
||||||
return RectorConfig::configure()
|
|
||||||
->withPaths([
|
|
||||||
__DIR__ . '/classes',
|
|
||||||
__DIR__ . '/interfaces',
|
|
||||||
__DIR__ . '/traits',
|
|
||||||
__DIR__ . '/objects',
|
|
||||||
__DIR__ . '/modules',
|
|
||||||
__DIR__ . '/routes',
|
|
||||||
__DIR__ . '/statistics',
|
|
||||||
__DIR__ . '/tests/Unit',
|
|
||||||
__DIR__ . '/tests/Integration',
|
|
||||||
__DIR__ . '/tests/Api',
|
|
||||||
])
|
|
||||||
->withBootstrapFiles([
|
|
||||||
__DIR__ . '/vendor/autoload.php',
|
|
||||||
])
|
|
||||||
->withSkip([
|
|
||||||
__DIR__ . '/build',
|
|
||||||
__DIR__ . '/vendor',
|
|
||||||
__DIR__ . '/.phpunit.cache',
|
|
||||||
__DIR__ . '/modules/*/vendor',
|
|
||||||
__DIR__ . '/modules/*/vendor/*',
|
|
||||||
__DIR__ . '/tests/Legacy',
|
|
||||||
])
|
|
||||||
->withPreparedSets(
|
|
||||||
codeQuality: true,
|
|
||||||
codingStyle: true,
|
|
||||||
phpunitCodeQuality: true,
|
|
||||||
);
|
|
||||||
@@ -398,18 +398,12 @@ class InvoicingPeriodRoute
|
|||||||
$limit = min(500, $limit);
|
$limit = min(500, $limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
$allowedFlagTabs = ['all' => true, 'red' => true, 'yellow' => true, 'none' => true, 'filters' => true];
|
|
||||||
$flagTab = trim((string)($parameters['flagTab'] ?? 'all'));
|
|
||||||
if ($flagTab === '' || !isset($allowedFlagTabs[$flagTab])) {
|
|
||||||
$flagTab = 'all';
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'periodView' => $periodView,
|
'periodView' => $periodView,
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
'limit' => $limit,
|
'limit' => $limit,
|
||||||
'search' => trim((string)($parameters['search'] ?? '')),
|
'search' => trim((string)($parameters['search'] ?? '')),
|
||||||
'flagTab' => $flagTab,
|
'flagTab' => trim((string)($parameters['flagTab'] ?? 'all')),
|
||||||
'includeRequiresAction' => self::parsePeriodBooleanOption(
|
'includeRequiresAction' => self::parsePeriodBooleanOption(
|
||||||
$parameters['includeRequiresAction'] ?? null,
|
$parameters['includeRequiresAction'] ?? null,
|
||||||
true
|
true
|
||||||
@@ -512,11 +506,25 @@ class InvoicingPeriodRoute
|
|||||||
$types[$viewName] = array_values(array_filter(
|
$types[$viewName] = array_values(array_filter(
|
||||||
is_array($entries) ? $entries : [],
|
is_array($entries) ? $entries : [],
|
||||||
static function (array $customer) use ($flagTab): bool {
|
static function (array $customer) use ($flagTab): bool {
|
||||||
|
$hasManual = false;
|
||||||
|
$hasAutomatic = false;
|
||||||
|
if (is_array($customer['flags'] ?? null)) {
|
||||||
|
foreach ($customer['flags'] as $flag) {
|
||||||
|
if (!empty($flag['order_id']) || !empty($flag['invoice_collection_id'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($flag['is_manual'] ?? ($flag['source'] ?? '') === 'manual') {
|
||||||
|
$hasManual = true;
|
||||||
|
} else {
|
||||||
|
$hasAutomatic = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$tab = 'none';
|
$tab = 'none';
|
||||||
$flagCounts = self::getActivePeriodFlagCounts($customer);
|
if ($hasManual) {
|
||||||
if ($flagCounts['manual'] > 0) {
|
|
||||||
$tab = 'red';
|
$tab = 'red';
|
||||||
} elseif ($flagCounts['automatic'] > 0) {
|
} elseif ($hasAutomatic) {
|
||||||
$tab = 'yellow';
|
$tab = 'yellow';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use classes\economic;
|
|||||||
use classes\email;
|
use classes\email;
|
||||||
use classes\release_manager;
|
use classes\release_manager;
|
||||||
use classes\recaptcha;
|
use classes\recaptcha;
|
||||||
use classes\slack;
|
|
||||||
use classes\totp;
|
use classes\totp;
|
||||||
use classes\virkdata;
|
use classes\virkdata;
|
||||||
use classes\webauthn;
|
use classes\webauthn;
|
||||||
@@ -409,7 +408,15 @@ class authRoute
|
|||||||
* Check if the cvr already exists
|
* Check if the cvr already exists
|
||||||
*/
|
*/
|
||||||
$economic = new economic();
|
$economic = new economic();
|
||||||
$economic_response = $this->searchEconomicCustomersByCvr($economic, (string)$cvr);
|
$economic_response = ($economic->customers->customers->search([
|
||||||
|
'corporateIdentificationNumber' => (string)$cvr,
|
||||||
|
], [
|
||||||
|
'skipPages' => 0,
|
||||||
|
'pageSize' => 1, // Since the limit is 1000, we need to set the page size to 1000.
|
||||||
|
])->collection);
|
||||||
|
if (!is_array($economic_response)) {
|
||||||
|
$economic_response = [];
|
||||||
|
}
|
||||||
|
|
||||||
$localUserExists = $this->localCustomerNumberExists($companyPhone);
|
$localUserExists = $this->localCustomerNumberExists($companyPhone);
|
||||||
$matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economic_response, $companyPhone);
|
$matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economic_response, $companyPhone);
|
||||||
@@ -440,66 +447,22 @@ class authRoute
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the CVR company information used for the e-conomic customer payload.
|
|
||||||
$companyInformation = null;
|
|
||||||
try {
|
|
||||||
$companyInformation = (new virkdata())->getCompanyInformation((string)$cvr, '', []);
|
|
||||||
} catch (Exception $exception) {
|
|
||||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOOKUP_FAILED', [
|
|
||||||
'phase' => 'cvr_lookup',
|
|
||||||
'cvr' => (string)$cvr,
|
|
||||||
'requestedCustomerNumber' => $companyPhone,
|
|
||||||
'message' => $exception->getMessage(),
|
|
||||||
]);
|
|
||||||
$response->error('CVR could not be verified. Please check the CVR number and try again.', 400);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$name = trim((string)($companyInformation->name ?? ''));
|
|
||||||
if ($name === '') {
|
|
||||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOOKUP_INVALID_RESPONSE', [
|
|
||||||
'phase' => 'cvr_lookup',
|
|
||||||
'cvr' => (string)$cvr,
|
|
||||||
'requestedCustomerNumber' => $companyPhone,
|
|
||||||
]);
|
|
||||||
$response->error('CVR could not be verified. Please check the CVR number and try again.', 400);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($localUserExists) {
|
if ($localUserExists) {
|
||||||
$response->error('Company phone number already registered', 400);
|
$response->error('Company phone number already registered', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Get the CVR company information used for the e-conomic customer payload.
|
||||||
$result = $economic->createCustomer(
|
$companyInformation = (new virkdata())->getCompanyInformation($cvr, '', []);
|
||||||
(int)$companyPhone,
|
$name = (string)($companyInformation->name ?? '');
|
||||||
$name,
|
$result = $economic->createCustomer(
|
||||||
(int)$cvr,
|
(int)$companyPhone,
|
||||||
(string)$invoiceEmail,
|
$name,
|
||||||
(int)$companyPhone,
|
(int)$cvr,
|
||||||
(int)$contactPhone,
|
(string)$invoiceEmail,
|
||||||
$companyInformation,
|
(int)$companyPhone,
|
||||||
);
|
(int)$contactPhone,
|
||||||
} catch (Exception $exception) {
|
$companyInformation,
|
||||||
$recoveredCustomer = $this->recoverRegistrationAfterCreateFailure(
|
);
|
||||||
$economic,
|
|
||||||
(string)$cvr,
|
|
||||||
$companyPhone,
|
|
||||||
(string)$invoiceEmail
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($recoveredCustomer !== null) {
|
|
||||||
$response->success($recoveredCustomer, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CREATE_FAILED', [
|
|
||||||
'phase' => 'create',
|
|
||||||
'cvr' => (string)$cvr,
|
|
||||||
'requestedCustomerNumber' => $companyPhone,
|
|
||||||
'message' => $exception->getMessage(),
|
|
||||||
]);
|
|
||||||
$response->error('Failed to create customer in e-conomic.', 502);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($result->customerNumber) || !is_numeric($result->customerNumber)) {
|
if (!isset($result->customerNumber) || !is_numeric($result->customerNumber)) {
|
||||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_INVALID_CREATE_RESPONSE', [
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_INVALID_CREATE_RESPONSE', [
|
||||||
@@ -531,7 +494,7 @@ class authRoute
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->bootstrapLocalCustomerOrFail($companyPhone, $result);
|
$this->bootstrapLocalCustomerOrFail($companyPhone);
|
||||||
$this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail);
|
$this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail);
|
||||||
$response->success($result, 201);
|
$response->success($result, 201);
|
||||||
});
|
});
|
||||||
@@ -570,8 +533,7 @@ class authRoute
|
|||||||
$reset_link = "https://truckwash.io/auth/password-reset/" . $token;
|
$reset_link = "https://truckwash.io/auth/password-reset/" . $token;
|
||||||
|
|
||||||
$subject = 'Adgangskode nulstilling';
|
$subject = 'Adgangskode nulstilling';
|
||||||
$valid_hours = (int)(customer_password_reset_keys_o::TOKEN_EXPIRY_SECONDS / 3600);
|
$message = "Du har anmodet om at nulstille din adgangskode. Klik på linket herunder for at fortsætte:<br><br><a href='$reset_link'>$reset_link</a><br><br>Linket er gyldigt i 1 time.";
|
||||||
$message = "Du har anmodet om at nulstille din adgangskode. Klik på linket herunder for at fortsætte:<br><br><a href='$reset_link'>$reset_link</a><br><br>Linket er gyldigt i $valid_hours timer.";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$email->sendEmail($email_address, $user->display_name->value() ?? 'Kunde', $subject, $message, null);
|
$email->sendEmail($email_address, $user->display_name->value() ?? 'Kunde', $subject, $message, null);
|
||||||
@@ -795,49 +757,6 @@ class authRoute
|
|||||||
return count($rows) > 0;
|
return count($rows) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function searchEconomicCustomersByCvr(economic $economic, string $cvr): array
|
|
||||||
{
|
|
||||||
$economic_response = ($economic->customers->customers->search([
|
|
||||||
'corporateIdentificationNumber' => $cvr,
|
|
||||||
], [
|
|
||||||
'skipPages' => 0,
|
|
||||||
'pageSize' => 1,
|
|
||||||
])->collection);
|
|
||||||
|
|
||||||
return is_array($economic_response) ? $economic_response : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function recoverRegistrationAfterCreateFailure(
|
|
||||||
economic $economic,
|
|
||||||
string $cvr,
|
|
||||||
int $customerNumber,
|
|
||||||
string $invoiceEmail
|
|
||||||
): ?object {
|
|
||||||
// The upstream POST can commit before the client receives a validation/transport error.
|
|
||||||
// Re-read by CVR and only recover when e-conomic confirms the requested customer number.
|
|
||||||
try {
|
|
||||||
$economic_response = $this->searchEconomicCustomersByCvr($economic, $cvr);
|
|
||||||
} catch (Exception $searchException) {
|
|
||||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CREATE_RECOVERY_SEARCH_FAILED', [
|
|
||||||
'phase' => 'create_recovery',
|
|
||||||
'cvr' => $cvr,
|
|
||||||
'requestedCustomerNumber' => $customerNumber,
|
|
||||||
'message' => $searchException->getMessage(),
|
|
||||||
]);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economic_response, $customerNumber);
|
|
||||||
if ($matchingEconomicCustomer === null || $this->localCustomerNumberExists($customerNumber)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->bootstrapLocalCustomerOrFail($customerNumber, $matchingEconomicCustomer);
|
|
||||||
$this->sendRegistrationWelcomeEmails($customerNumber, $invoiceEmail);
|
|
||||||
|
|
||||||
return $matchingEconomicCustomer;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object
|
private function findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object
|
||||||
{
|
{
|
||||||
foreach ($customers as $customer) {
|
foreach ($customers as $customer) {
|
||||||
@@ -865,46 +784,19 @@ class authRoute
|
|||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
private function bootstrapLocalCustomerOrFail(int $customerNumber, ?object $economicCustomer = null): users_o
|
private function bootstrapLocalCustomerOrFail(int $customerNumber): users_o
|
||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
$customer = new users_o();
|
$customer = (new users_o())->getUserByCustomerNumber($customerNumber);
|
||||||
try {
|
if (method_exists($customer, 'exists') && $customer->exists()) {
|
||||||
$customer = $customer->getUserByCustomerNumber($customerNumber);
|
return $customer;
|
||||||
if (method_exists($customer, 'exists') && $customer->exists()) {
|
|
||||||
return $customer;
|
|
||||||
}
|
|
||||||
} catch (Exception $exception) {
|
|
||||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_BOOTSTRAP_LOOKUP_FAILED', [
|
|
||||||
'customerNumber' => $customerNumber,
|
|
||||||
'message' => $exception->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
$economicCustomer !== null
|
|
||||||
&& $this->extractEconomicCustomerNumber($economicCustomer) === $customerNumber
|
|
||||||
&& method_exists($customer, 'importCustomerFromEconomicCustomerData')
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
$importedCustomer = $customer->importCustomerFromEconomicCustomerData($economicCustomer);
|
|
||||||
if (is_object($importedCustomer) && method_exists($importedCustomer, 'exists') && $importedCustomer->exists()) {
|
|
||||||
return $importedCustomer;
|
|
||||||
}
|
|
||||||
} catch (Exception $exception) {
|
|
||||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_SNAPSHOT_BOOTSTRAP_FAILED', [
|
|
||||||
'customerNumber' => $customerNumber,
|
|
||||||
'message' => $exception->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_BOOTSTRAP_FAILED', [
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_BOOTSTRAP_FAILED', [
|
||||||
'customerNumber' => $customerNumber,
|
'customerNumber' => $customerNumber,
|
||||||
]);
|
]);
|
||||||
$response->error('Customer was created in e-conomic but could not be imported locally.', 500);
|
$response->error('Customer was created in e-conomic but could not be imported locally.', 500);
|
||||||
throw new Exception('Customer was created in e-conomic but could not be imported locally.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -918,24 +810,6 @@ class authRoute
|
|||||||
//$email->sendWelcomeEmailToCustomer($customerNumber, (string)$infoEmail); - Disabled, requested by Christian.
|
//$email->sendWelcomeEmailToCustomer($customerNumber, (string)$infoEmail); - Disabled, requested by Christian.
|
||||||
$email->sendWelcomeEmailToCustomer($customerNumber, $jimmyEmail);
|
$email->sendWelcomeEmailToCustomer($customerNumber, $jimmyEmail);
|
||||||
$email->sendWelcomeEmailToCustomer($customerNumber, $invoiceEmail);
|
$email->sendWelcomeEmailToCustomer($customerNumber, $invoiceEmail);
|
||||||
|
|
||||||
try {
|
|
||||||
$email->sendNewCustomerRegistrationNotifications($customerNumber);
|
|
||||||
} catch (Exception $exception) {
|
|
||||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_SUPERUSER_NOTIFICATION_FAILED', [
|
|
||||||
'customerNumber' => $customerNumber,
|
|
||||||
'message' => $exception->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
(new slack())->send_customer_registration_notification($customerNumber);
|
|
||||||
} catch (Exception $exception) {
|
|
||||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED', [
|
|
||||||
'customerNumber' => $customerNumber,
|
|
||||||
'message' => $exception->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function logRegisterCvrIssue(string $action, array $context): void
|
private function logRegisterCvrIssue(string $action, array $context): void
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ use classes\authentication;
|
|||||||
use classes\response;
|
use classes\response;
|
||||||
use classes\shelly_relay_inventory;
|
use classes\shelly_relay_inventory;
|
||||||
use dynamicimages\images\machine_1;
|
use dynamicimages\images\machine_1;
|
||||||
use modules\selfserve\config\selfserve_dynamic_image_size_c;
|
|
||||||
use modules\selfserve\selfserve_c;
|
|
||||||
use objects\categories_o;
|
use objects\categories_o;
|
||||||
use objects\department_lanes_o;
|
use objects\department_lanes_o;
|
||||||
use objects\department_selfserve_tasks_o;
|
use objects\department_selfserve_tasks_o;
|
||||||
@@ -18,8 +16,6 @@ class departmentLanesRoute
|
|||||||
{
|
{
|
||||||
use route_t;
|
use route_t;
|
||||||
|
|
||||||
private const RELEVANT_DYNAMIC_IMAGE_MAX_WIDTH = 1600;
|
|
||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$this->get('/department/lanes/status-toggles', function () {
|
$this->get('/department/lanes/status-toggles', function () {
|
||||||
@@ -223,7 +219,6 @@ class departmentLanesRoute
|
|||||||
$response->error('No dynamic image configured for this lane', 404);
|
$response->error('No dynamic image configured for this lane', 404);
|
||||||
}
|
}
|
||||||
$dynamic_image_id = (int)$dynamic_image_id;
|
$dynamic_image_id = (int)$dynamic_image_id;
|
||||||
$dynamic_image_size = self::getSelfServeDynamicImageSizeMode();
|
|
||||||
|
|
||||||
// Parse optional params
|
// Parse optional params
|
||||||
$buttons = null;
|
$buttons = null;
|
||||||
@@ -271,21 +266,27 @@ class departmentLanesRoute
|
|||||||
// Cache check
|
// Cache check
|
||||||
$cacheKey = null;
|
$cacheKey = null;
|
||||||
if (defined('redis')) {
|
if (defined('redis')) {
|
||||||
$cacheKey = self::buildDynamicImageCacheKey([
|
// Cache only the default image variant to avoid unbounded cache key growth
|
||||||
'dynamic_image_id' => $dynamic_image_id,
|
// from request-controlled parameters (buttons/current_step/etc.).
|
||||||
'buttons' => $buttons,
|
$isDefaultVariant = $buttons === null
|
||||||
'current_step' => $current_step,
|
&& $current_step === 0
|
||||||
'only_current_step' => $only_current_step,
|
&& !(bool)$only_current_step
|
||||||
'vehicle_type' => $vehicle_type,
|
&& $vehicle_type === null;
|
||||||
'dynamic_image_size' => $dynamic_image_size,
|
|
||||||
'thumb_position' => $thumb_position,
|
if ($isDefaultVariant) {
|
||||||
]);
|
$cacheParams = [
|
||||||
$cachedImage = $cacheKey === null ? false : redis->get($cacheKey);
|
'department' => $department_id,
|
||||||
if ($cachedImage) {
|
'lane' => $lane_id,
|
||||||
header('Content-Type: image/png');
|
'dynamic_image_id' => $dynamic_image_id,
|
||||||
header('Content-Length: ' . strlen($cachedImage));
|
];
|
||||||
echo $cachedImage;
|
$cacheKey = 'dynamic_image:' . md5(json_encode($cacheParams));
|
||||||
exit;
|
$cachedImage = redis->get($cacheKey);
|
||||||
|
if ($cachedImage) {
|
||||||
|
header('Content-Type: image/png');
|
||||||
|
header('Content-Length: ' . strlen($cachedImage));
|
||||||
|
echo $cachedImage;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,17 +313,20 @@ class departmentLanesRoute
|
|||||||
// Compose and serve the image
|
// Compose and serve the image
|
||||||
try {
|
try {
|
||||||
$image->setup();
|
$image->setup();
|
||||||
if ($dynamic_image_size === selfserve_dynamic_image_size_c::SIZE_RELEVANT) {
|
|
||||||
$image->resizeToMaxWidth(self::RELEVANT_DYNAMIC_IMAGE_MAX_WIDTH);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// If caching is enabled, we need to capture the output or use export
|
||||||
if ($cacheKey && defined('redis')) {
|
if ($cacheKey && defined('redis')) {
|
||||||
$imageData = $image->exportBinary('png');
|
$dataUri = $image->exportAsBase64('png');
|
||||||
redis->setEx($cacheKey, $imageData, 86400); // Cache for 24 hours
|
if (preg_match('/^data:image\/png;base64,(.*)$/', $dataUri, $matches)) {
|
||||||
header('Content-Type: image/png');
|
$imageData = base64_decode($matches[1]);
|
||||||
header('Content-Length: ' . strlen($imageData));
|
if ($imageData !== false) {
|
||||||
echo $imageData;
|
redis->setEx($cacheKey, $imageData, 86400); // Cache for 24 hours
|
||||||
exit;
|
header('Content-Type: image/png');
|
||||||
|
header('Content-Length: ' . strlen($imageData));
|
||||||
|
echo $imageData;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$image->servePicture('png');
|
$image->servePicture('png');
|
||||||
@@ -523,47 +527,4 @@ class departmentLanesRoute
|
|||||||
|
|
||||||
$field->set($normalized);
|
$field->set($normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function getSelfServeDynamicImageSizeMode(): string
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$mode = (string)(new selfserve_c())->dynamic_image_size->getVariableValue();
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return selfserve_dynamic_image_size_c::SIZE_ORIGINAL;
|
|
||||||
}
|
|
||||||
|
|
||||||
return in_array($mode, [selfserve_dynamic_image_size_c::SIZE_ORIGINAL, selfserve_dynamic_image_size_c::SIZE_RELEVANT], true)
|
|
||||||
? $mode
|
|
||||||
: selfserve_dynamic_image_size_c::SIZE_ORIGINAL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{
|
|
||||||
* dynamic_image_id:int,
|
|
||||||
* buttons:array<int|string>|null,
|
|
||||||
* current_step:int,
|
|
||||||
* only_current_step:bool,
|
|
||||||
* vehicle_type:int|null,
|
|
||||||
* dynamic_image_size:string,
|
|
||||||
* thumb_position:int|null
|
|
||||||
* } $variant
|
|
||||||
*/
|
|
||||||
private static function buildDynamicImageCacheKey(array $variant): ?string
|
|
||||||
{
|
|
||||||
$cacheParams = [
|
|
||||||
'dynamic_image_id' => (int)$variant['dynamic_image_id'],
|
|
||||||
'buttons' => $variant['buttons'],
|
|
||||||
'current_step' => (int)$variant['current_step'],
|
|
||||||
'only_current_step' => (bool)$variant['only_current_step'],
|
|
||||||
'vehicle_type' => $variant['vehicle_type'],
|
|
||||||
'dynamic_image_size' => (string)$variant['dynamic_image_size'],
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($variant['thumb_position'] !== null) {
|
|
||||||
$cacheParams['thumb_position'] = (int)$variant['thumb_position'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$json = json_encode($cacheParams);
|
|
||||||
return $json === false ? null : 'dynamic_image:' . md5($json);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ use objects\customer_vehicles_o;
|
|||||||
use objects\department_lanes_o;
|
use objects\department_lanes_o;
|
||||||
use objects\department_selfserve_tasks_o;
|
use objects\department_selfserve_tasks_o;
|
||||||
use objects\department_selfserve_vehicle_conditions_o;
|
use objects\department_selfserve_vehicle_conditions_o;
|
||||||
use objects\departments_o;
|
|
||||||
use objects\logs_o;
|
use objects\logs_o;
|
||||||
use objects\selfserve_wash_sessions_o;
|
|
||||||
use traits\route_t;
|
use traits\route_t;
|
||||||
|
|
||||||
class departmentSelfserveVehicleConditionsRoute
|
class departmentSelfserveVehicleConditionsRoute
|
||||||
@@ -133,18 +131,16 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
$lane_id = (int)self::getParameter('lane_id');
|
$lane_id = (int)self::getParameter('lane_id');
|
||||||
$reg = selfserve::standardize_registration((string)self::getParameter('reg'));
|
$reg = selfserve::standardize_registration((string)self::getParameter('reg'));
|
||||||
|
|
||||||
$lane = $this->assertLaneAccess($user, $lane_id, $has_global, $has_own);
|
$lane = $this->assertLaneAccess($user, $lane_id);
|
||||||
$customer_number = null;
|
$customer_number = null;
|
||||||
if ($has_own && (!$has_global || !$this->userHasLaneDepartmentAccess($user, $lane))) {
|
if (!$has_global && $has_own) {
|
||||||
$customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions');
|
$customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions');
|
||||||
}
|
}
|
||||||
$vehicle_type_id = $this->resolveVehicleTypeIdFromQuery();
|
$vehicle_type_id = $this->resolveVehicleTypeIdFromQuery();
|
||||||
$flow = $this->getWashFlow();
|
$flow = $this->getWashFlow();
|
||||||
|
|
||||||
if ($vehicle_type_id !== null) {
|
if ($vehicle_type_id !== null) {
|
||||||
$flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false, [
|
$flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false);
|
||||||
'create_session' => false,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
(new logs_o())->add('department_selfserve_vehicle_conditions', (int)$lane->department->value(), 1, $user->id, 'CHECK_VEHICLE_ALLOWED', 'User checked self-serve eligibility for lane ' . $lane_id . ' and vehicle ' . $reg);
|
(new logs_o())->add('department_selfserve_vehicle_conditions', (int)$lane->department->value(), 1, $user->id, 'CHECK_VEHICLE_ALLOWED', 'User checked self-serve eligibility for lane ' . $lane_id . ' and vehicle ' . $reg);
|
||||||
@@ -175,10 +171,10 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
try {
|
try {
|
||||||
if (self::isParametersSet(['session_id'])) {
|
if (self::isParametersSet(['session_id'])) {
|
||||||
$summary = $flow->getSessionSummary((int)self::getParameter('session_id'));
|
$summary = $flow->getSessionSummary((int)self::getParameter('session_id'));
|
||||||
$this->assertSummaryAccess($user, $summary, $has_global, $has_own, 'list_department_selfserve_vehicle_conditions');
|
$this->assertSummaryAccess($user, $summary, $has_global, 'list_department_selfserve_vehicle_conditions');
|
||||||
|
|
||||||
if ($this->shouldRefreshSummaryForVehicleType($summary, $vehicle_type_id)) {
|
if ($this->shouldRefreshSummaryForVehicleType($summary, $vehicle_type_id)) {
|
||||||
$refreshed_summary = $flow->synchronizeSession(
|
$summary = $flow->synchronizeSession(
|
||||||
(int)($summary['session']['lane_id'] ?? 0),
|
(int)($summary['session']['lane_id'] ?? 0),
|
||||||
(string)($summary['session']['reg'] ?? ''),
|
(string)($summary['session']['reg'] ?? ''),
|
||||||
isset($summary['session']['customer_number']) && $summary['session']['customer_number'] !== null
|
isset($summary['session']['customer_number']) && $summary['session']['customer_number'] !== null
|
||||||
@@ -186,12 +182,8 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
: null,
|
: null,
|
||||||
false,
|
false,
|
||||||
$vehicle_type_id,
|
$vehicle_type_id,
|
||||||
false,
|
false
|
||||||
['create_session' => false]
|
|
||||||
);
|
);
|
||||||
if (!empty($refreshed_summary['session']['id'])) {
|
|
||||||
$summary = $refreshed_summary;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$response->success($summary);
|
$response->success($summary);
|
||||||
@@ -201,27 +193,18 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
$lane_id = (int)self::getParameter('lane_id');
|
$lane_id = (int)self::getParameter('lane_id');
|
||||||
$reg = selfserve::standardize_registration((string)self::getParameter('reg'));
|
$reg = selfserve::standardize_registration((string)self::getParameter('reg'));
|
||||||
|
|
||||||
$lane = $this->assertLaneAccess($user, $lane_id, $has_global, $has_own);
|
$this->assertLaneAccess($user, $lane_id);
|
||||||
$customer_number = null;
|
$customer_number = null;
|
||||||
if ($has_own && (!$has_global || !$this->userHasLaneDepartmentAccess($user, $lane))) {
|
if (!$has_global && $has_own) {
|
||||||
$customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions');
|
$customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($vehicle_type_id !== null) {
|
if ($vehicle_type_id !== null) {
|
||||||
$summary = $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false, [
|
$summary = $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false);
|
||||||
'create_session' => false,
|
|
||||||
]);
|
|
||||||
if (empty($summary['session']['id'])) {
|
|
||||||
try {
|
|
||||||
$summary = $flow->getLatestSessionSummary($lane_id, $reg);
|
|
||||||
} catch (\RuntimeException) {
|
|
||||||
// Keep the read-only snapshot when no previous wash exists.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
$summary = $flow->getLatestSessionSummary($lane_id, $reg);
|
$summary = $flow->getLatestSessionSummary($lane_id, $reg);
|
||||||
}
|
}
|
||||||
$this->assertSummaryAccess($user, $summary, $has_global, $has_own, 'list_department_selfserve_vehicle_conditions');
|
$this->assertSummaryAccess($user, $summary, $has_global, 'list_department_selfserve_vehicle_conditions');
|
||||||
$response->success($summary);
|
$response->success($summary);
|
||||||
} catch (\RuntimeException $e) {
|
} catch (\RuntimeException $e) {
|
||||||
$response->error($e->getMessage(), 404);
|
$response->error($e->getMessage(), 404);
|
||||||
@@ -507,10 +490,6 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
$session = is_array($summary['session'] ?? null) ? $summary['session'] : [];
|
$session = is_array($summary['session'] ?? null) ? $summary['session'] : [];
|
||||||
if (($session['completed_at'] ?? null) !== null || selfserve_wash_sessions_o::isTerminalStatus($session['status'] ?? null)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sessionVehicleTypeId = isset($session['vehicle_type_id']) && $session['vehicle_type_id'] !== null
|
$sessionVehicleTypeId = isset($session['vehicle_type_id']) && $session['vehicle_type_id'] !== null
|
||||||
? (int)$session['vehicle_type_id']
|
? (int)$session['vehicle_type_id']
|
||||||
: null;
|
: null;
|
||||||
@@ -548,13 +527,7 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
return $default;
|
return $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function assertLaneAccess(
|
private function assertLaneAccess(object $user, int $laneId): department_lanes_o
|
||||||
object $user,
|
|
||||||
int $laneId,
|
|
||||||
bool $hasGlobalPermission = true,
|
|
||||||
bool $hasOwnPermission = false,
|
|
||||||
string $elevatedPermission = 'list_department_selfserve_vehicle_conditions'
|
|
||||||
): department_lanes_o
|
|
||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
@@ -563,77 +536,17 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
$response->error('Department lane not found', 404);
|
$response->error('Department lane not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($hasGlobalPermission && $this->userHasLaneDepartmentAccess($user, $lane)) {
|
|
||||||
return $lane;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($hasOwnPermission && $this->isCustomerSelfServeLaneEnabled($lane)) {
|
|
||||||
return $lane;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($hasGlobalPermission) {
|
|
||||||
$lane_department_id = (int)$lane->department->value();
|
|
||||||
$this->forbidDepartmentAccess($lane_department_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
$response->forbidden([$elevatedPermission]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function userHasLaneDepartmentAccess(object $user, department_lanes_o $lane): bool
|
|
||||||
{
|
|
||||||
$lane_department_id = (int)$lane->department->value();
|
$lane_department_id = (int)$lane->department->value();
|
||||||
$authorized_department_ids = array_values(array_filter(
|
$authorized_department_ids = array_values(array_filter(
|
||||||
array_map('intval', (array)$user->getGroup()->getDepartments()),
|
array_map('intval', (array)$user->getGroup()->getDepartments()),
|
||||||
static fn(int $department_id): bool => $department_id > 0
|
static fn(int $department_id): bool => $department_id > 0
|
||||||
));
|
));
|
||||||
|
|
||||||
return in_array($lane_department_id, $authorized_department_ids, true);
|
if (!in_array($lane_department_id, $authorized_department_ids, true)) {
|
||||||
}
|
$this->forbidDepartmentAccess($lane_department_id);
|
||||||
|
|
||||||
private function isCustomerSelfServeLaneEnabled(department_lanes_o $lane): bool
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
if (!$lane->isSelfServeEnabled()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$department = (new departments_o())->select((int)$lane->department->value());
|
|
||||||
return $department->exists() && $department->getSelfServeEnabled();
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function summaryBelongsToCustomer(object $user, array $summary): bool
|
|
||||||
{
|
|
||||||
$session_customer_number = $summary['session']['customer_number'] ?? null;
|
|
||||||
if ($session_customer_number !== null && (int)$session_customer_number === (int)$user->customer_number->value()) {
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$reg = (string)($summary['session']['reg'] ?? '');
|
return $lane;
|
||||||
$vehicle_o = (new customer_vehicles_o())->selectByPlate($reg);
|
|
||||||
return $vehicle_o->exists() && (int)$vehicle_o->customer_id->value() === (int)$user->customer_number->value();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function summaryDepartmentId(array $summary): int
|
|
||||||
{
|
|
||||||
return (int)($summary['lane']['department'] ?? $summary['session']['department_id'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function userHasSummaryDepartmentAccess(object $user, array $summary): bool
|
|
||||||
{
|
|
||||||
$lane_department = $this->summaryDepartmentId($summary);
|
|
||||||
if ($lane_department <= 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$authorized_department_ids = array_values(array_filter(
|
|
||||||
array_map('intval', (array)$user->getGroup()->getDepartments()),
|
|
||||||
static fn(int $department_id): bool => $department_id > 0
|
|
||||||
));
|
|
||||||
|
|
||||||
return in_array($lane_department, $authorized_department_ids, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function requireAuthenticatedCustomerNumber(object $user, string $elevatedPermission): int
|
private function requireAuthenticatedCustomerNumber(object $user, string $elevatedPermission): int
|
||||||
@@ -648,26 +561,28 @@ class departmentSelfserveVehicleConditionsRoute
|
|||||||
return $customer_number;
|
return $customer_number;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function assertSummaryAccess(
|
private function assertSummaryAccess(object $user, array $summary, bool $hasGlobalPermission, string $elevatedPermission): void
|
||||||
object $user,
|
|
||||||
array $summary,
|
|
||||||
bool $hasGlobalPermission,
|
|
||||||
bool $hasOwnPermission,
|
|
||||||
string $elevatedPermission
|
|
||||||
): void
|
|
||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
if ($hasGlobalPermission && $this->userHasSummaryDepartmentAccess($user, $summary)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($hasOwnPermission && $this->summaryBelongsToCustomer($user, $summary)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($hasGlobalPermission) {
|
if ($hasGlobalPermission) {
|
||||||
$this->forbidDepartmentAccess($this->summaryDepartmentId($summary));
|
$authorized_department_ids = $user->getGroup()->getDepartments();
|
||||||
|
$lane_department = (int)($summary['lane']['department'] ?? 0);
|
||||||
|
if (!in_array($lane_department, $authorized_department_ids, true)) {
|
||||||
|
$this->forbidDepartmentAccess($lane_department);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session_customer_number = $summary['session']['customer_number'] ?? null;
|
||||||
|
if ($session_customer_number !== null && (int)$session_customer_number === (int)$user->customer_number->value()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reg = (string)($summary['session']['reg'] ?? '');
|
||||||
|
$vehicle_o = (new customer_vehicles_o())->selectByPlate($reg);
|
||||||
|
if ($vehicle_o->exists() && (int)$vehicle_o->customer_id->value() === (int)$user->customer_number->value()) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$response->forbidden([$elevatedPermission]);
|
$response->forbidden([$elevatedPermission]);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ use classes\n8n;
|
|||||||
use classes\recaptcha;
|
use classes\recaptcha;
|
||||||
use classes\response;
|
use classes\response;
|
||||||
use classes\router;
|
use classes\router;
|
||||||
use classes\slack;
|
|
||||||
use classes\stripe;
|
use classes\stripe;
|
||||||
use classes\weatherapi;
|
use classes\weatherapi;
|
||||||
use classes\workfeed;
|
use classes\workfeed;
|
||||||
@@ -181,75 +180,6 @@ class moduleConfigRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Slack config > GET */
|
|
||||||
$this->get('/slack/config', function () {
|
|
||||||
global $response;
|
|
||||||
$this->requirePermission('slack_config');
|
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
if ($user) {
|
|
||||||
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_CONFIG', 'Successfully fetched Slack config');
|
|
||||||
$response->success(
|
|
||||||
(new slack())->getConfig()->getConfigRequest()
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_CONFIG', 'No user found, or invalid session');
|
|
||||||
$response->error('Invalid session', 400);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
'slack_config' => 'Get Slack config'
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Slack config > POST */
|
|
||||||
$this->post('/slack/config', function () {
|
|
||||||
global $response;
|
|
||||||
$this->requirePermission('slack_config');
|
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
if ($user) {
|
|
||||||
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_CONFIG', 'Successfully updated Slack config');
|
|
||||||
$response->success(
|
|
||||||
(new slack())->getConfig()->postConfigRequest()
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_CONFIG', 'No user found, or invalid session');
|
|
||||||
$response->error('Invalid session', 400);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
'slack_config' => 'Update Slack config'
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Slack config > TEST */
|
|
||||||
$this->post('/slack/config/test', function () {
|
|
||||||
global $response;
|
|
||||||
$this->requirePermission('slack_config');
|
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
if (!$user) {
|
|
||||||
(new logs_o())->add('slack_config', 'global', 1, 0, 'SLACK_CONFIG', 'No user found, or invalid session');
|
|
||||||
$response->error('Invalid session', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = (new slack())->test_customer_registration_webhook();
|
|
||||||
if (($result['configured'] ?? false) !== true) {
|
|
||||||
(new logs_o())->add('slack_config', 'global', 0, $user->id, 'SLACK_CONFIG_TEST', 'Slack customer registration webhook URL is not configured');
|
|
||||||
$response->error($result['message'] ?? 'Slack customer registration webhook URL is not configured.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (($result['sent'] ?? false) !== true) {
|
|
||||||
(new logs_o())->add('slack_config', 'global', 0, $user->id, 'SLACK_CONFIG_TEST', 'Slack customer registration test webhook failed');
|
|
||||||
$response->error($result['message'] ?? 'Slack test message failed.', 502);
|
|
||||||
}
|
|
||||||
|
|
||||||
(new logs_o())->add('slack_config', 'global', 1, $user->id, 'SLACK_CONFIG_TEST', 'Successfully tested Slack customer registration webhook');
|
|
||||||
$response->success($result);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
'slack_config' => 'Test Slack config'
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->get('/backups/config', function () {
|
$this->get('/backups/config', function () {
|
||||||
global $response;
|
global $response;
|
||||||
$this->requirePermission('backups_config');
|
$this->requirePermission('backups_config');
|
||||||
|
|||||||
@@ -2,35 +2,21 @@
|
|||||||
|
|
||||||
namespace routes;
|
namespace routes;
|
||||||
|
|
||||||
|
use classes\authentication;
|
||||||
|
use classes\image_processor;
|
||||||
use classes\licenseplaterecognizer;
|
use classes\licenseplaterecognizer;
|
||||||
|
use classes\openai;
|
||||||
use classes\response;
|
use classes\response;
|
||||||
|
use classes\router;
|
||||||
|
use classes\upload_store;
|
||||||
|
use Exception;
|
||||||
|
use objects\logs_o;
|
||||||
use traits\route_t;
|
use traits\route_t;
|
||||||
|
|
||||||
class moduleScannerRoute
|
class moduleScannerRoute
|
||||||
{
|
{
|
||||||
use route_t;
|
use route_t;
|
||||||
|
|
||||||
private const LPR_IMAGE_UPLOAD_FIELD = 'image';
|
|
||||||
private const LPR_IMAGE_UPLOAD_MAX_BYTES = 4194304;
|
|
||||||
private const LPR_CLIENT_CAPTURE_MS_FIELD = 'client_capture_ms';
|
|
||||||
private const LPR_CLIENT_CAPTURE_MAX_MS = 10000;
|
|
||||||
private const LPR_CLIENT_DRAW_MS_FIELD = 'client_draw_ms';
|
|
||||||
private const LPR_CLIENT_ENCODE_MS_FIELD = 'client_encode_ms';
|
|
||||||
private const LPR_CLIENT_FRAME_WIDTH_FIELD = 'client_frame_width';
|
|
||||||
private const LPR_CLIENT_FRAME_HEIGHT_FIELD = 'client_frame_height';
|
|
||||||
private const LPR_CLIENT_FRAME_BYTES_FIELD = 'client_frame_bytes';
|
|
||||||
private const LPR_CLIENT_FRAME_MAX_DIMENSION = 4096;
|
|
||||||
private const LPR_CLIENT_PREFLIGHT_MS_FIELD = 'client_preflight_ms';
|
|
||||||
private const LPR_CLIENT_VISUAL_FINGERPRINT_MS_FIELD = 'client_visual_fingerprint_ms';
|
|
||||||
private const LPR_CLIENT_CAPTURE_MS_HEADER = 'HTTP_X_LPR_CLIENT_CAPTURE_MS';
|
|
||||||
private const LPR_CLIENT_DRAW_MS_HEADER = 'HTTP_X_LPR_CLIENT_DRAW_MS';
|
|
||||||
private const LPR_CLIENT_ENCODE_MS_HEADER = 'HTTP_X_LPR_CLIENT_ENCODE_MS';
|
|
||||||
private const LPR_CLIENT_FRAME_WIDTH_HEADER = 'HTTP_X_LPR_CLIENT_FRAME_WIDTH';
|
|
||||||
private const LPR_CLIENT_FRAME_HEIGHT_HEADER = 'HTTP_X_LPR_CLIENT_FRAME_HEIGHT';
|
|
||||||
private const LPR_CLIENT_FRAME_BYTES_HEADER = 'HTTP_X_LPR_CLIENT_FRAME_BYTES';
|
|
||||||
private const LPR_CLIENT_PREFLIGHT_MS_HEADER = 'HTTP_X_LPR_CLIENT_PREFLIGHT_MS';
|
|
||||||
private const LPR_CLIENT_VISUAL_FINGERPRINT_MS_HEADER = 'HTTP_X_LPR_CLIENT_VISUAL_FINGERPRINT_MS';
|
|
||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
global /** @var response $response */
|
global /** @var response $response */
|
||||||
@@ -38,43 +24,27 @@ class moduleScannerRoute
|
|||||||
/** Modules > Scanner > License Plate Recognition > POST */
|
/** Modules > Scanner > License Plate Recognition > POST */
|
||||||
$this->post('/modules/scanner/lpr', function () {
|
$this->post('/modules/scanner/lpr', function () {
|
||||||
global $response;
|
global $response;
|
||||||
$route_started_at = microtime(true);
|
self::requireParameters(['base64_image']);
|
||||||
$image_upload = self::getLPRImageUpload();
|
$base64_image = self::getParameter('base64_image');
|
||||||
$raw_image_upload = $image_upload === null ? self::getLPRRawImageUpload() : null;
|
|
||||||
$client_timings = self::getLPRClientTimings();
|
|
||||||
$base64_image = null;
|
|
||||||
//self::requirePermission('modules_scanner_lpr');
|
//self::requirePermission('modules_scanner_lpr');
|
||||||
if ($image_upload === null && $raw_image_upload === null) {
|
if (empty($base64_image)) {
|
||||||
$base64_image = self::getParameter('base64_image');
|
$response->error('Base64 image is required.');}
|
||||||
if (!is_string($base64_image) || trim($base64_image) === '') {
|
$uploads = new upload_store();
|
||||||
$response->error('Image is required.');
|
$object_name = $uploads->storeTempImageFromBase64(
|
||||||
}
|
$base64_image
|
||||||
}
|
);
|
||||||
$recognizer = new licenseplaterecognizer(false);
|
//echo $object_name;
|
||||||
try {
|
//echo "License Plate Recognition result:\n";
|
||||||
if ($image_upload !== null) {
|
// Uncomment the line below to use the actual license plate recognizer.
|
||||||
$lpr_result = $recognizer->licenseplaterecognizerUploadFile($image_upload['path'], $image_upload['mime_type']);
|
$lpr_result = (new licenseplaterecognizer())->licenseplaterecognizer($base64_image);
|
||||||
} elseif ($raw_image_upload !== null) {
|
|
||||||
$lpr_result = $recognizer->licenseplaterecognizerUploadUncached($raw_image_upload['data'], $raw_image_upload['mime_type']);
|
|
||||||
} else {
|
|
||||||
$lpr_result = $recognizer->licenseplaterecognizer((string)$base64_image);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
self::sendLPRServerTiming(array_merge($client_timings, $recognizer->getLastTimings()), $route_started_at);
|
|
||||||
}
|
|
||||||
if ($lpr_result['success']) {
|
if ($lpr_result['success']) {
|
||||||
// Set the LICENSE_PLATE_NUMBER to uppercase and remove spaces.
|
// Set the LICENSE_PLATE_NUMBER to uppercase and remove spaces.
|
||||||
$lpr_result['license_plate_number'] = strtoupper(str_replace(' ', '', $lpr_result['license_plate_number']));
|
$lpr_result['license_plate_number'] = strtoupper(str_replace(' ', '', $lpr_result['license_plate_number']));
|
||||||
// Make sure the scanned plate is more than 3 characters long.
|
// Make sure the scanned plate is more than 3 characters long.
|
||||||
$scannedPlateIsTooShort = strlen($lpr_result['license_plate_number']) <= 3;
|
$scannedPlateIsTooShort = strlen($lpr_result['license_plate_number']) <= 3;
|
||||||
// If the confidence is below 90%, treat it as a recoverable scanner miss.
|
// If the confidence is below 90%, consider it a failure.
|
||||||
if (isset($lpr_result['confidence']) && $lpr_result['confidence'] < 0.9 && !$scannedPlateIsTooShort) {
|
if (isset($lpr_result['confidence']) && $lpr_result['confidence'] < 0.9 && !$scannedPlateIsTooShort) {
|
||||||
$response->response(false, [
|
throw new Exception('License plate recognition confidence too low. Score: ' . $lpr_result['confidence'] . ' Plate: ' . $lpr_result['license_plate_number'] . ' Raw: ' . json_encode($lpr_result['raw_response']));
|
||||||
'message' => 'License plate recognition confidence too low.',
|
|
||||||
'reason' => 'low_confidence_license_plate',
|
|
||||||
'confidence' => $lpr_result['confidence'],
|
|
||||||
'license_plate_number' => $lpr_result['license_plate_number'],
|
|
||||||
], 200);
|
|
||||||
}
|
}
|
||||||
// Success
|
// Success
|
||||||
$response->success(['success' => true, 'license_plate_number' => $lpr_result['license_plate_number']]);
|
$response->success(['success' => true, 'license_plate_number' => $lpr_result['license_plate_number']]);
|
||||||
@@ -84,296 +54,25 @@ class moduleScannerRoute
|
|||||||
'reason' => 'no_license_plate_detected',
|
'reason' => 'no_license_plate_detected',
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
exit;
|
||||||
|
// For future use with OpenAI.
|
||||||
|
$openai = new openai();
|
||||||
|
$registration_numbers_debug = [
|
||||||
|
'EC21233',
|
||||||
|
'EC21234',
|
||||||
|
'EC21235',
|
||||||
|
];
|
||||||
|
$random_index = rand(0, count($registration_numbers_debug) - 1);
|
||||||
|
$object_name = $registration_numbers_debug[$random_index];
|
||||||
|
// Attempt to recognize the license plate number from the image.
|
||||||
|
|
||||||
|
//Debug: TODO: Remove this.
|
||||||
|
$response->success(['success' => true, 'license_plate_number' => $object_name]);
|
||||||
|
$response->success($openai->lpr($object_name));
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
'modules_scanner_lpr' => 'License Plate Recognition',
|
'modules_scanner_lpr' => 'License Plate Recognition',
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function getLPRImageUpload(): ?array
|
|
||||||
{
|
|
||||||
global $response;
|
|
||||||
|
|
||||||
if (!isset($_FILES[self::LPR_IMAGE_UPLOAD_FIELD]) || !is_array($_FILES[self::LPR_IMAGE_UPLOAD_FIELD])) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = $_FILES[self::LPR_IMAGE_UPLOAD_FIELD];
|
|
||||||
if (is_array($file['error'] ?? null)) {
|
|
||||||
$response->error('Only one image can be uploaded.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$upload_error = (int)($file['error'] ?? UPLOAD_ERR_NO_FILE);
|
|
||||||
if ($upload_error === UPLOAD_ERR_NO_FILE) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($upload_error !== UPLOAD_ERR_OK) {
|
|
||||||
$response->error('Image upload failed.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$size = (int)($file['size'] ?? 0);
|
|
||||||
if ($size <= 0) {
|
|
||||||
$response->error('Image upload is empty.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($size > self::LPR_IMAGE_UPLOAD_MAX_BYTES) {
|
|
||||||
$response->error('Image upload is too large.', 413);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tmp_name = (string)($file['tmp_name'] ?? '');
|
|
||||||
if ($tmp_name === '' || !is_uploaded_file($tmp_name)) {
|
|
||||||
$response->error('Image upload is invalid.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!is_readable($tmp_name)) {
|
|
||||||
$response->error('Image upload could not be read.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$mime_type = self::detectLPRImageMimeType($file, $tmp_name);
|
|
||||||
if (!str_starts_with($mime_type, 'image/')) {
|
|
||||||
$response->error('Image upload must be an image.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'path' => $tmp_name,
|
|
||||||
'mime_type' => $mime_type,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function getLPRRawImageUpload(): ?array
|
|
||||||
{
|
|
||||||
global $response;
|
|
||||||
|
|
||||||
$mime_type = self::getRequestContentType();
|
|
||||||
if (!str_starts_with($mime_type, 'image/')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$content_length = isset($_SERVER['CONTENT_LENGTH']) && is_numeric($_SERVER['CONTENT_LENGTH'])
|
|
||||||
? (int)$_SERVER['CONTENT_LENGTH']
|
|
||||||
: null;
|
|
||||||
if ($content_length !== null && $content_length > self::LPR_IMAGE_UPLOAD_MAX_BYTES) {
|
|
||||||
$response->error('Image upload is too large.', 413);
|
|
||||||
}
|
|
||||||
|
|
||||||
$image_data = file_get_contents('php://input');
|
|
||||||
if (!is_string($image_data) || $image_data === '') {
|
|
||||||
$response->error('Image upload is empty.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strlen($image_data) > self::LPR_IMAGE_UPLOAD_MAX_BYTES) {
|
|
||||||
$response->error('Image upload is too large.', 413);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'data' => $image_data,
|
|
||||||
'mime_type' => $mime_type,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function getRequestContentType(): string
|
|
||||||
{
|
|
||||||
$content_type = (string)($_SERVER['CONTENT_TYPE'] ?? $_SERVER['HTTP_CONTENT_TYPE'] ?? '');
|
|
||||||
$content_type = strtolower(trim(explode(';', $content_type, 2)[0] ?? ''));
|
|
||||||
|
|
||||||
return $content_type;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function detectLPRImageMimeType(array $file, string $tmp_name): string
|
|
||||||
{
|
|
||||||
$mime_type = trim((string)($file['type'] ?? ''));
|
|
||||||
if ($mime_type !== '') {
|
|
||||||
return $mime_type;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (class_exists(\finfo::class)) {
|
|
||||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
|
||||||
$detected = $finfo->file($tmp_name);
|
|
||||||
if (is_string($detected) && $detected !== '') {
|
|
||||||
return $detected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'image/jpeg';
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function getLPRClientTimings(): array
|
|
||||||
{
|
|
||||||
$timings = [];
|
|
||||||
$client_capture_ms = self::getNumericClientField(
|
|
||||||
self::LPR_CLIENT_CAPTURE_MS_FIELD,
|
|
||||||
self::LPR_CLIENT_CAPTURE_MS_HEADER,
|
|
||||||
0,
|
|
||||||
self::LPR_CLIENT_CAPTURE_MAX_MS
|
|
||||||
);
|
|
||||||
if ($client_capture_ms !== null) {
|
|
||||||
$timings['client_capture'] = $client_capture_ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
$client_preflight_ms = self::getNumericClientField(
|
|
||||||
self::LPR_CLIENT_PREFLIGHT_MS_FIELD,
|
|
||||||
self::LPR_CLIENT_PREFLIGHT_MS_HEADER,
|
|
||||||
0,
|
|
||||||
self::LPR_CLIENT_CAPTURE_MAX_MS
|
|
||||||
);
|
|
||||||
if ($client_preflight_ms !== null) {
|
|
||||||
$timings['client_preflight'] = $client_preflight_ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
$client_draw_ms = self::getNumericClientField(
|
|
||||||
self::LPR_CLIENT_DRAW_MS_FIELD,
|
|
||||||
self::LPR_CLIENT_DRAW_MS_HEADER,
|
|
||||||
0,
|
|
||||||
self::LPR_CLIENT_CAPTURE_MAX_MS
|
|
||||||
);
|
|
||||||
if ($client_draw_ms !== null) {
|
|
||||||
$timings['client_draw'] = $client_draw_ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
$client_encode_ms = self::getNumericClientField(
|
|
||||||
self::LPR_CLIENT_ENCODE_MS_FIELD,
|
|
||||||
self::LPR_CLIENT_ENCODE_MS_HEADER,
|
|
||||||
0,
|
|
||||||
self::LPR_CLIENT_CAPTURE_MAX_MS
|
|
||||||
);
|
|
||||||
if ($client_encode_ms !== null) {
|
|
||||||
$timings['client_encode'] = $client_encode_ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
$client_visual_fingerprint_ms = self::getNumericClientField(
|
|
||||||
self::LPR_CLIENT_VISUAL_FINGERPRINT_MS_FIELD,
|
|
||||||
self::LPR_CLIENT_VISUAL_FINGERPRINT_MS_HEADER,
|
|
||||||
0,
|
|
||||||
self::LPR_CLIENT_CAPTURE_MAX_MS
|
|
||||||
);
|
|
||||||
if ($client_visual_fingerprint_ms !== null) {
|
|
||||||
$timings['client_visual_fingerprint'] = $client_visual_fingerprint_ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
$client_frame_width = self::getNumericClientField(
|
|
||||||
self::LPR_CLIENT_FRAME_WIDTH_FIELD,
|
|
||||||
self::LPR_CLIENT_FRAME_WIDTH_HEADER,
|
|
||||||
1,
|
|
||||||
self::LPR_CLIENT_FRAME_MAX_DIMENSION
|
|
||||||
);
|
|
||||||
if ($client_frame_width !== null) {
|
|
||||||
$timings['client_frame_width'] = $client_frame_width;
|
|
||||||
}
|
|
||||||
|
|
||||||
$client_frame_height = self::getNumericClientField(
|
|
||||||
self::LPR_CLIENT_FRAME_HEIGHT_FIELD,
|
|
||||||
self::LPR_CLIENT_FRAME_HEIGHT_HEADER,
|
|
||||||
1,
|
|
||||||
self::LPR_CLIENT_FRAME_MAX_DIMENSION
|
|
||||||
);
|
|
||||||
if ($client_frame_height !== null) {
|
|
||||||
$timings['client_frame_height'] = $client_frame_height;
|
|
||||||
}
|
|
||||||
|
|
||||||
$client_frame_bytes = self::getNumericClientField(
|
|
||||||
self::LPR_CLIENT_FRAME_BYTES_FIELD,
|
|
||||||
self::LPR_CLIENT_FRAME_BYTES_HEADER,
|
|
||||||
1,
|
|
||||||
self::LPR_IMAGE_UPLOAD_MAX_BYTES
|
|
||||||
);
|
|
||||||
if ($client_frame_bytes !== null) {
|
|
||||||
$timings['client_frame_bytes'] = $client_frame_bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $timings;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function getNumericClientField(string $field, string $server_header, float $min, float $max): ?float
|
|
||||||
{
|
|
||||||
$value = $_GET[$field] ?? null;
|
|
||||||
if ($value === null) {
|
|
||||||
$value = $_POST[$field] ?? null;
|
|
||||||
}
|
|
||||||
if ($value === null) {
|
|
||||||
$value = $_SERVER[$server_header] ?? null;
|
|
||||||
}
|
|
||||||
if (is_array($value) || !is_numeric($value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = (float)$value;
|
|
||||||
if ($value < $min || $value > $max) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function sendLPRServerTiming(array $timings, float $route_started_at): void
|
|
||||||
{
|
|
||||||
if (headers_sent()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = [];
|
|
||||||
$timings['route_total'] = max(0, (microtime(true) - $route_started_at) * 1000);
|
|
||||||
$timings['local'] = self::getLPRLocalDuration($timings);
|
|
||||||
if (isset($_SERVER['REQUEST_TIME_FLOAT']) && is_numeric($_SERVER['REQUEST_TIME_FLOAT'])) {
|
|
||||||
$timings['request_total'] = max(0, (microtime(true) - (float)$_SERVER['REQUEST_TIME_FLOAT']) * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ([
|
|
||||||
'client_capture',
|
|
||||||
'client_preflight',
|
|
||||||
'client_visual_fingerprint',
|
|
||||||
'client_draw',
|
|
||||||
'client_encode',
|
|
||||||
'client_frame_width',
|
|
||||||
'client_frame_height',
|
|
||||||
'client_frame_bytes',
|
|
||||||
'config',
|
|
||||||
'cache',
|
|
||||||
'cache_hit',
|
|
||||||
'cache_miss',
|
|
||||||
'local',
|
|
||||||
'payload',
|
|
||||||
'upstream_dns',
|
|
||||||
'upstream_connect',
|
|
||||||
'upstream_tls',
|
|
||||||
'upstream_pretransfer',
|
|
||||||
'upstream_ttfb',
|
|
||||||
'upstream_total',
|
|
||||||
'upstream_processing',
|
|
||||||
'upstream',
|
|
||||||
'parse',
|
|
||||||
'total',
|
|
||||||
] as $name) {
|
|
||||||
if (!isset($timings[$name]) || !is_numeric($timings[$name])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts[] = sprintf('lpr_%s;dur=%.3F', $name, (float)$timings[$name]);
|
|
||||||
}
|
|
||||||
foreach (['route_total', 'request_total'] as $name) {
|
|
||||||
if (!isset($timings[$name]) || !is_numeric($timings[$name])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts[] = sprintf('lpr_%s;dur=%.3F', $name, (float)$timings[$name]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($parts !== []) {
|
|
||||||
header('Server-Timing: ' . implode(', ', $parts));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function getLPRLocalDuration(array $timings): float
|
|
||||||
{
|
|
||||||
$upstream = null;
|
|
||||||
foreach (['upstream', 'upstream_total'] as $name) {
|
|
||||||
if (isset($timings[$name]) && is_numeric($timings[$name])) {
|
|
||||||
$upstream = max(0, (float)$timings[$name]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return max(0, (float)$timings['route_total'] - ($upstream ?? 0));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use classes\response;
|
|||||||
use classes\router;
|
use classes\router;
|
||||||
use classes\selfserve;
|
use classes\selfserve;
|
||||||
use classes\stripe;
|
use classes\stripe;
|
||||||
use modules\selfserve\classes\selfserve_config_versioning;
|
|
||||||
use modules\selfserve\classes\selfserve_lane;
|
use modules\selfserve\classes\selfserve_lane;
|
||||||
use modules\selfserve\classes\selfserve_wash_flow;
|
use modules\selfserve\classes\selfserve_wash_flow;
|
||||||
use modules\selfserve\helpers\selfserve_lane_command;
|
use modules\selfserve\helpers\selfserve_lane_command;
|
||||||
@@ -23,7 +22,6 @@ use objects\logs_o;
|
|||||||
use objects\orders_o;
|
use objects\orders_o;
|
||||||
use objects\customer_vehicles_o;
|
use objects\customer_vehicles_o;
|
||||||
use objects\selfserve_wash_sessions_o;
|
use objects\selfserve_wash_sessions_o;
|
||||||
use objects\selfserve_wash_session_tasks_o;
|
|
||||||
use objects\stripe_module_customers_o;
|
use objects\stripe_module_customers_o;
|
||||||
use objects\users_o;
|
use objects\users_o;
|
||||||
use traits\route_t;
|
use traits\route_t;
|
||||||
@@ -569,30 +567,6 @@ class moduleSelfServeRoute
|
|||||||
'subuser_id' => $subuser === false ? null : (int)$subuser->id,
|
'subuser_id' => $subuser === false ? null : (int)$subuser->id,
|
||||||
]);
|
]);
|
||||||
$lane->execute($command, $args);
|
$lane->execute($command, $args);
|
||||||
if ($command === selfserve_lane_command::START) {
|
|
||||||
try {
|
|
||||||
$license_plate = selfserve::standardize_registration(
|
|
||||||
(string)($args->license_plate ?: $lane->getLicensePlate())
|
|
||||||
);
|
|
||||||
if ($license_plate !== '') {
|
|
||||||
(new selfserve_wash_flow())->synchronizeSession(
|
|
||||||
$lane_id,
|
|
||||||
$license_plate,
|
|
||||||
$customer_number > 0 ? $customer_number : null,
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
error_log(
|
|
||||||
'Failed to persist self-serve START session for lane '
|
|
||||||
. $lane_id
|
|
||||||
. ': '
|
|
||||||
. $e->getMessage()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$response->success([
|
$response->success([
|
||||||
'id' => $lane->id,
|
'id' => $lane->id,
|
||||||
'status' => $lane->getLaneStatus()->name,
|
'status' => $lane->getLaneStatus()->name,
|
||||||
@@ -656,64 +630,23 @@ class moduleSelfServeRoute
|
|||||||
// Build allowed services from provided tasks
|
// Build allowed services from provided tasks
|
||||||
$lane = $selfserve->lane($lane_id);
|
$lane = $selfserve->lane($lane_id);
|
||||||
$customer_number = $this->resolveEffectiveCustomerNumber();
|
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||||
$this->requireSelfServeLaneAccess(
|
self::requirePermission('modules_selfserve_lane_services_set_allowed');
|
||||||
$lane,
|
|
||||||
$customer_number === null ? 0 : (int)$customer_number,
|
|
||||||
['modules_selfserve_lane_services_set_allowed'],
|
|
||||||
false
|
|
||||||
);
|
|
||||||
$session_task_services = [];
|
|
||||||
$latest_session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane(
|
|
||||||
$lane_id,
|
|
||||||
$customer_number === null || (int)$customer_number <= 0 ? null : (int)$customer_number
|
|
||||||
);
|
|
||||||
if (!$latest_session->exists() && $customer_number !== null && (int)$customer_number > 0) {
|
|
||||||
$latest_session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane($lane_id);
|
|
||||||
}
|
|
||||||
if ($latest_session->exists()) {
|
|
||||||
foreach ((new selfserve_wash_session_tasks_o())->listBySession((int)$latest_session->id) as $session_task) {
|
|
||||||
$task_id = (int)($session_task['task_id'] ?? 0);
|
|
||||||
if ($task_id > 0) {
|
|
||||||
$services = $session_task['services'] ?? [];
|
|
||||||
if (is_string($services)) {
|
|
||||||
$decoded_services = json_decode($services, true);
|
|
||||||
$services = json_last_error() === JSON_ERROR_NONE && is_array($decoded_services)
|
|
||||||
? $decoded_services
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
$session_task_services[$task_id] = is_array($services) ? $services : [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$allowed_services = [];
|
$allowed_services = [];
|
||||||
$published_config_task_services = $this->publishedConfigTaskServicesForLane($lane, $task_ids);
|
|
||||||
$merge_services = static function (array $services) use (&$allowed_services): void {
|
|
||||||
foreach ($services as $srv) {
|
|
||||||
$name = strtoupper((string)$srv);
|
|
||||||
if (!in_array($name, $allowed_services, true)) {
|
|
||||||
$allowed_services[] = $name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
foreach ($task_ids as $tid) {
|
foreach ($task_ids as $tid) {
|
||||||
if ($tid <= 0) continue;
|
if ($tid <= 0) continue;
|
||||||
if (array_key_exists($tid, $session_task_services)) {
|
|
||||||
$merge_services($session_task_services[$tid]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (array_key_exists($tid, $published_config_task_services)) {
|
|
||||||
$merge_services($published_config_task_services[$tid]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$t = new \objects\department_selfserve_tasks_o();
|
$t = new \objects\department_selfserve_tasks_o();
|
||||||
$t->select($tid);
|
$t->select($tid);
|
||||||
if (!$t->exists()) continue; // ignore unknown ids
|
if (!$t->exists()) continue; // ignore unknown ids
|
||||||
// Validate the task belongs to the same lane
|
// Validate the task belongs to the same lane
|
||||||
if ((int)$t->lane->value() !== $lane_id) continue;
|
if ((int)$t->lane->value() !== $lane_id) continue;
|
||||||
// Merge services (if any)
|
// Merge services (if any)
|
||||||
$merge_services((array)$t->services->value());
|
$services = (array)$t->services->value();
|
||||||
|
foreach ($services as $srv) {
|
||||||
|
$name = strtoupper((string)$srv);
|
||||||
|
if (!in_array($name, $allowed_services, true)) {
|
||||||
|
$allowed_services[] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Persist on lane cache (overwrites previous allowed services)
|
// Persist on lane cache (overwrites previous allowed services)
|
||||||
try {
|
try {
|
||||||
@@ -1670,13 +1603,9 @@ class moduleSelfServeRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($allow_customer_self_serve) {
|
if ($allow_customer_self_serve) {
|
||||||
if ($requires_active_wash && $allow_department_active_wash) {
|
$customer_allowed = $requires_active_wash
|
||||||
$customer_allowed = $this->canCustomerUsePropertyGateForLane($lane, $customer_number);
|
? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, $allow_department_active_wash)
|
||||||
} else {
|
: $this->canCustomerUseSelfServeLane($lane, $customer_number);
|
||||||
$customer_allowed = $requires_active_wash
|
|
||||||
? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, false)
|
|
||||||
: $this->canCustomerUseSelfServeLane($lane, $customer_number);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($customer_allowed) {
|
if ($customer_allowed) {
|
||||||
return;
|
return;
|
||||||
@@ -1831,17 +1760,7 @@ class moduleSelfServeRoute
|
|||||||
|
|
||||||
protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
|
protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
|
||||||
{
|
{
|
||||||
if ($this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, true)) {
|
return $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, true);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($customer_number <= 0 || !$this->isOwnCustomerContext($customer_number)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$department_id = $this->departmentIdForLane($lane);
|
|
||||||
return $department_id > 0
|
|
||||||
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function canCustomerUseActiveOperationalSelfServeLane(
|
protected function canCustomerUseActiveOperationalSelfServeLane(
|
||||||
@@ -1980,59 +1899,6 @@ class moduleSelfServeRoute
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int,int> $task_ids
|
|
||||||
* @return array<int,array<int,string>>
|
|
||||||
*/
|
|
||||||
private function publishedConfigTaskServicesForLane(selfserve_lane $lane, array $task_ids): array
|
|
||||||
{
|
|
||||||
$department_id = $this->departmentIdForLane($lane);
|
|
||||||
if ($department_id <= 0 || $task_ids === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$published = (new selfserve_config_versioning())->getPublishedConfig($department_id);
|
|
||||||
$config = is_array($published) ? (array)($published['config'] ?? []) : [];
|
|
||||||
$tasks = is_array($config['tasks'] ?? null) ? $config['tasks'] : [];
|
|
||||||
if ($tasks === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$requested_task_ids = array_fill_keys(array_map(static fn($task_id): int => (int)$task_id, $task_ids), true);
|
|
||||||
$services_by_task_id = [];
|
|
||||||
foreach ($tasks as $task) {
|
|
||||||
if (!is_array($task)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$task_id = (int)($task['id'] ?? $task['task_id'] ?? 0);
|
|
||||||
if ($task_id <= 0 || !isset($requested_task_ids[$task_id])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$task_department_id = (int)($task['department'] ?? $task['department_id'] ?? 0);
|
|
||||||
if ($task_department_id !== 0 && $task_department_id !== $department_id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$task_lane_id = (int)($task['lane'] ?? $task['lane_id'] ?? 0);
|
|
||||||
if ($task_lane_id !== 0 && $task_lane_id !== (int)$lane->id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$services = $task['services'] ?? [];
|
|
||||||
if (is_string($services)) {
|
|
||||||
$decoded = json_decode($services, true);
|
|
||||||
$services = json_last_error() === JSON_ERROR_NONE && is_array($decoded) ? $decoded : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$services_by_task_id[$task_id] = is_array($services) ? $services : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $services_by_task_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function requestedShellyTransportOverride(): ?string
|
private function requestedShellyTransportOverride(): ?string
|
||||||
{
|
{
|
||||||
$transport = null;
|
$transport = null;
|
||||||
|
|||||||
@@ -392,37 +392,6 @@ class orderBookingRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->post('/order-bookings/completion-confirmation/resend', function () {
|
|
||||||
global $response;
|
|
||||||
|
|
||||||
$object = self::getTargetObject();
|
|
||||||
if (!$object || !$object->exists()) {
|
|
||||||
$response->error('Order booking does not exist.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
self::requirePermission('complete_bookings');
|
|
||||||
self::requireDepartmentAccess((int)$object->department->value());
|
|
||||||
|
|
||||||
if (!$object->hasTransaction()) {
|
|
||||||
$response->error('Order booking has not been completed yet.', 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$object->getOrder()->hasWashCertificateAttached()) {
|
|
||||||
$response->error('Order booking completion confirmation is not available yet.', 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
(new email())->sendWashCertificateEmailToCustomer($object);
|
|
||||||
|
|
||||||
$response->success([
|
|
||||||
'message' => 'Completion confirmation resent successfully.',
|
|
||||||
'booking' => $object->asArray(),
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
'complete_bookings' => 'Permission for department admins to resend order booking completion confirmation emails.'
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->post('/order-bookings/complete', function () {
|
$this->post('/order-bookings/complete', function () {
|
||||||
// Require the user to be logged in
|
// Require the user to be logged in
|
||||||
global $response;
|
global $response;
|
||||||
|
|||||||
@@ -496,49 +496,6 @@ class orderInvoicesRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Collected order invoices > Move to customer > POST */
|
|
||||||
$this->post('/collected-invoices/move-to-customer', function () {
|
|
||||||
global $response;
|
|
||||||
self::requirePermission('move_collected_invoice_customer');
|
|
||||||
$user = (new authentication())->get_user();
|
|
||||||
if (!$user) {
|
|
||||||
(new logs_o())->add('orderInvoices', 'global', 0, 0, 'MOVE_COLLECTED_INVOICE_CUSTOMER', 'User tried to move a collected order invoice without a valid session');
|
|
||||||
$response->error('Invalid session', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
self::requireParameters(['id', 'customer_number']);
|
|
||||||
self::requireType((int)self::getParameter('id'), self::type_int());
|
|
||||||
self::requireType((int)self::getParameter('customer_number'), self::type_int());
|
|
||||||
self::requireMinValue((int)self::getParameter('id'), 1);
|
|
||||||
self::requireMinValue((int)self::getParameter('customer_number'), 1);
|
|
||||||
self::requireMaxValue((int)self::getParameter('customer_number'), 999999999);
|
|
||||||
|
|
||||||
$collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id'));
|
|
||||||
$collected_order_invoices->requireSelected();
|
|
||||||
|
|
||||||
try {
|
|
||||||
$move_result = $collected_order_invoices->moveToCustomer((int)self::getParameter('customer_number'));
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$response->error($e->getMessage(), 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
(new logs_o())->add(
|
|
||||||
'orderInvoices',
|
|
||||||
'global',
|
|
||||||
1,
|
|
||||||
$user->id,
|
|
||||||
'MOVE_COLLECTED_INVOICE_CUSTOMER',
|
|
||||||
'User moved collected order invoice #' . (int)$collected_order_invoices->id . ' to customer #' . (int)self::getParameter('customer_number')
|
|
||||||
);
|
|
||||||
|
|
||||||
$response->add_meta('move', $move_result);
|
|
||||||
$response->success($collected_order_invoices->asArray());
|
|
||||||
},
|
|
||||||
[
|
|
||||||
'move_collected_invoice_customer' => 'Move a collected order invoice and its orders to another customer. This is a superuser-only route.'
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Collected order invoices > Split > POST */
|
/** Collected order invoices > Split > POST */
|
||||||
$this->post('/collected-invoices/split', function () {
|
$this->post('/collected-invoices/split', function () {
|
||||||
global $response;
|
global $response;
|
||||||
|
|||||||
@@ -97,6 +97,40 @@ class superuserCoolifyRoute
|
|||||||
'superuser_coolify_manage' => 'Deploy the latest Coolify API code for the public gateway host',
|
'superuser_coolify_manage' => 'Deploy the latest Coolify API code for the public gateway host',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
$this->get('/superuser/coolify/github-runners', function () {
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
$this->requirePermission('superuser_coolify_view');
|
||||||
|
$response->success((new coolify_manager())->githubRunnerSummary());
|
||||||
|
}, [
|
||||||
|
'superuser_coolify_view' => 'View Coolify-managed GitHub Actions runner state',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->post('/superuser/coolify/github-runners/deploy', function () {
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
$this->requirePermission('superuser_coolify_manage');
|
||||||
|
try {
|
||||||
|
$parameters = $this->getParametersAsArray();
|
||||||
|
$parameters['dry_run'] = array_key_exists('dry_run', $parameters)
|
||||||
|
? filter_var($parameters['dry_run'], FILTER_VALIDATE_BOOLEAN)
|
||||||
|
: !filter_var($parameters['enforce'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
$result = (new coolify_manager())->deployGithubRunners(
|
||||||
|
$parameters,
|
||||||
|
$this->actorUserId()
|
||||||
|
);
|
||||||
|
if (($result['ok'] ?? false) !== true) {
|
||||||
|
$response->error($result, 409);
|
||||||
|
}
|
||||||
|
$response->success($result);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
$response->error(['message' => $throwable->getMessage()], 409);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
'superuser_coolify_manage' => 'Create or update the Coolify-managed GitHub Actions runner service',
|
||||||
|
]);
|
||||||
|
|
||||||
$this->get('/superuser/coolify/gateways', function () {
|
$this->get('/superuser/coolify/gateways', function () {
|
||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ namespace routes;
|
|||||||
|
|
||||||
use classes\authentication;
|
use classes\authentication;
|
||||||
use objects\logs_o;
|
use objects\logs_o;
|
||||||
use objects\users_o;
|
|
||||||
use traits\route_t;
|
use traits\route_t;
|
||||||
|
|
||||||
class userNotificationsRoute
|
class userNotificationsRoute
|
||||||
@@ -29,9 +28,6 @@ class userNotificationsRoute
|
|||||||
$wash_certificate_email = self::isParametersSet(['wash_certificate_email']) ? (string)self::getParameter('wash_certificate_email') : null;
|
$wash_certificate_email = self::isParametersSet(['wash_certificate_email']) ? (string)self::getParameter('wash_certificate_email') : null;
|
||||||
$sms_notifications_enabled = self::isParametersSet(['sms_notifications_enabled']) ? (bool)self::getParameter('sms_notifications_enabled') : null;
|
$sms_notifications_enabled = self::isParametersSet(['sms_notifications_enabled']) ? (bool)self::getParameter('sms_notifications_enabled') : null;
|
||||||
$email_notifications_enabled = self::isParametersSet(['email_notifications_enabled']) ? (bool)self::getParameter('email_notifications_enabled') : null;
|
$email_notifications_enabled = self::isParametersSet(['email_notifications_enabled']) ? (bool)self::getParameter('email_notifications_enabled') : null;
|
||||||
$superuser_new_customer_email_notifications_enabled = self::isParametersSet([users_o::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS])
|
|
||||||
? (bool)self::getParameter(users_o::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS)
|
|
||||||
: null;
|
|
||||||
/**
|
/**
|
||||||
* Wash Certificate Email
|
* Wash Certificate Email
|
||||||
*/
|
*/
|
||||||
@@ -59,22 +55,6 @@ class userNotificationsRoute
|
|||||||
self::requireType($email_notifications_enabled, self::type_bool());
|
self::requireType($email_notifications_enabled, self::type_bool());
|
||||||
$user->email_notifications_enabled->set($email_notifications_enabled ? 1 : 0);
|
$user->email_notifications_enabled->set($email_notifications_enabled ? 1 : 0);
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Superuser New Customer Email Notifications Enabled
|
|
||||||
*/
|
|
||||||
if ($superuser_new_customer_email_notifications_enabled !== null) {
|
|
||||||
self::requirePermission('superuser');
|
|
||||||
self::requireType($superuser_new_customer_email_notifications_enabled, self::type_bool());
|
|
||||||
$user->setSuperuserNewCustomerEmailNotificationsEnabled($superuser_new_customer_email_notifications_enabled);
|
|
||||||
}
|
|
||||||
$token = str_replace('Bearer ', '', (string)($_SERVER['HTTP_AUTHORIZATION'] ?? ''));
|
|
||||||
if ($token !== '') {
|
|
||||||
try {
|
|
||||||
redis->clear_auth_session($token);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// Session cache invalidation is best-effort; the persistent update above is authoritative.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Log the update
|
// Log the update
|
||||||
(new logs_o())->add('user_notifications', 'global', 0, $user->id, 'USER_NOTIFICATIONS_UPDATE', 'User notification settings updated');
|
(new logs_o())->add('user_notifications', 'global', 0, $user->id, 'USER_NOTIFICATIONS_UPDATE', 'User notification settings updated');
|
||||||
// Return success
|
// Return success
|
||||||
@@ -85,4 +65,4 @@ class userNotificationsRoute
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
usesApiSuite();
|
|
||||||
|
|
||||||
function moved_invoice_collection_customer_number(int $invoiceCollectionId): int
|
|
||||||
{
|
|
||||||
$row = api_test_runtime()->queryOne('SELECT customer_number FROM collected_order_invoices WHERE id = ' . $invoiceCollectionId . ' LIMIT 1');
|
|
||||||
return (int)($row['customer_number'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function moved_order_customer_number(int $orderId): int
|
|
||||||
{
|
|
||||||
$row = api_test_runtime()->queryOne('SELECT customer_id FROM orders WHERE id = ' . $orderId . ' LIMIT 1');
|
|
||||||
return (int)($row['customer_id'] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('moves a collected invoice collection and all attached orders to another customer', function (): void {
|
|
||||||
api_test_covers('POST /collected-invoices/move-to-customer', 'happy');
|
|
||||||
|
|
||||||
$sourceCustomer = api_fixtures()->createUser(['display_name' => 'Move Source Customer']);
|
|
||||||
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Move Target Customer']);
|
|
||||||
$department = api_fixtures()->createDepartment();
|
|
||||||
$invoiceCollection = api_fixtures()->createInvoiceCollection([
|
|
||||||
'customer_number' => $sourceCustomer['customer_number'],
|
|
||||||
]);
|
|
||||||
$firstOrder = api_fixtures()->createOrder([
|
|
||||||
'customer_id' => $sourceCustomer['customer_number'],
|
|
||||||
'department_id' => $department['id'],
|
|
||||||
'invoice_collection_id' => $invoiceCollection['id'],
|
|
||||||
]);
|
|
||||||
$secondOrder = api_fixtures()->createOrder([
|
|
||||||
'customer_id' => $sourceCustomer['customer_number'],
|
|
||||||
'department_id' => $department['id'],
|
|
||||||
'invoice_collection_id' => $invoiceCollection['id'],
|
|
||||||
]);
|
|
||||||
$session = api_fixtures()->createUserSession(['move_collected_invoice_customer']);
|
|
||||||
|
|
||||||
$response = api_client()->post('/collected-invoices/move-to-customer', [
|
|
||||||
'id' => $invoiceCollection['id'],
|
|
||||||
'customer_number' => $targetCustomer['customer_number'],
|
|
||||||
], $session['headers']);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertEnvelope()
|
|
||||||
->assertSuccess();
|
|
||||||
|
|
||||||
$payload = $response->data();
|
|
||||||
$moveMeta = $response->meta()['move'] ?? [];
|
|
||||||
|
|
||||||
expect($payload['id'] ?? null)->toBe((int)$invoiceCollection['id'])
|
|
||||||
->and($payload['customer_number'] ?? null)->toBe((int)$targetCustomer['customer_number'])
|
|
||||||
->and($moveMeta['source_customer_number'] ?? null)->toBe((int)$sourceCustomer['customer_number'])
|
|
||||||
->and($moveMeta['target_customer_number'] ?? null)->toBe((int)$targetCustomer['customer_number'])
|
|
||||||
->and($moveMeta['moved_order_count'] ?? null)->toBe(2)
|
|
||||||
->and(moved_invoice_collection_customer_number((int)$invoiceCollection['id']))->toBe((int)$targetCustomer['customer_number'])
|
|
||||||
->and(moved_order_customer_number((int)$firstOrder['id']))->toBe((int)$targetCustomer['customer_number'])
|
|
||||||
->and(moved_order_customer_number((int)$secondOrder['id']))->toBe((int)$targetCustomer['customer_number']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects moving a collection that already has an external invoice reference', function (): void {
|
|
||||||
api_test_covers('POST /collected-invoices/move-to-customer', 'external-guard');
|
|
||||||
|
|
||||||
$sourceCustomer = api_fixtures()->createUser(['display_name' => 'Move External Source Customer']);
|
|
||||||
$targetCustomer = api_fixtures()->createUser(['display_name' => 'Move External Target Customer']);
|
|
||||||
$department = api_fixtures()->createDepartment();
|
|
||||||
$invoiceCollection = api_fixtures()->createInvoiceCollection([
|
|
||||||
'customer_number' => $sourceCustomer['customer_number'],
|
|
||||||
'external_id' => 'external-invoice-123',
|
|
||||||
]);
|
|
||||||
$order = api_fixtures()->createOrder([
|
|
||||||
'customer_id' => $sourceCustomer['customer_number'],
|
|
||||||
'department_id' => $department['id'],
|
|
||||||
'invoice_collection_id' => $invoiceCollection['id'],
|
|
||||||
]);
|
|
||||||
$session = api_fixtures()->createUserSession(['move_collected_invoice_customer']);
|
|
||||||
|
|
||||||
$response = api_client()->post('/collected-invoices/move-to-customer', [
|
|
||||||
'id' => $invoiceCollection['id'],
|
|
||||||
'customer_number' => $targetCustomer['customer_number'],
|
|
||||||
], $session['headers']);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertStatus(400)
|
|
||||||
->assertEnvelope()
|
|
||||||
->assertSuccess(false);
|
|
||||||
|
|
||||||
expect($response->data()['message'] ?? '')->toContain('external or booked invoice')
|
|
||||||
->and(moved_invoice_collection_customer_number((int)$invoiceCollection['id']))->toBe((int)$sourceCustomer['customer_number'])
|
|
||||||
->and(moved_order_customer_number((int)$order['id']))->toBe((int)$sourceCustomer['customer_number']);
|
|
||||||
});
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Tests\Support\Api\ApiServer;
|
|
||||||
|
|
||||||
usesApiSuite();
|
|
||||||
|
|
||||||
it('rejects scanner LPR requests without an image before contacting Plate Recognizer', function (): void {
|
|
||||||
api_test_covers('POST /modules/scanner/lpr', 'failure');
|
|
||||||
|
|
||||||
$response = api_client()->post('/modules/scanner/lpr', []);
|
|
||||||
|
|
||||||
$response->assertStatus(400)->assertSuccess(false)->assertMessage('Image is required.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts multipart scanner images and forwards them to Plate Recognizer as a temp-file upload', function (): void {
|
|
||||||
api_test_covers('POST /modules/scanner/lpr', 'happy');
|
|
||||||
|
|
||||||
if (trim((string)getenv('API_TEST_BASE_URL')) !== '') {
|
|
||||||
$this->markTestSkipped('This scanner LPR test requires the self-started API server so PLATE_RECOGNIZER_API_URL can be isolated.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$fakePlateRecognizer = ScannerLprFakePlateRecognizerServer::start();
|
|
||||||
$previousPlateRecognizerUrl = scanner_lpr_api_get_env('PLATE_RECOGNIZER_API_URL');
|
|
||||||
scanner_lpr_api_set_env('PLATE_RECOGNIZER_API_URL', $fakePlateRecognizer->url());
|
|
||||||
api_test_runtime()->restartServer();
|
|
||||||
|
|
||||||
$imagePath = tempnam(sys_get_temp_dir(), 'scanner-lpr-api-');
|
|
||||||
expect($imagePath)->not->toBeFalse();
|
|
||||||
file_put_contents($imagePath, 'jpeg-camera-bytes');
|
|
||||||
|
|
||||||
try {
|
|
||||||
api_fixtures()->setModuleConfig('licenseplaterecognizer', 'enabled', 'true', 'bool');
|
|
||||||
api_fixtures()->setModuleConfig('licenseplaterecognizer', 'api_key', 'scanner-api-test-key', 'string');
|
|
||||||
api_test_runtime()->redis()?->del(['licenseplaterecognizer:runtime_config:v1']);
|
|
||||||
|
|
||||||
$response = api_client()->postMultipart('/modules/scanner/lpr', [
|
|
||||||
'client_capture_ms' => '12.345',
|
|
||||||
'client_frame_width' => '300',
|
|
||||||
'client_frame_height' => '225',
|
|
||||||
'client_frame_bytes' => (string)strlen('jpeg-camera-bytes'),
|
|
||||||
], [
|
|
||||||
'image' => [
|
|
||||||
'path' => $imagePath,
|
|
||||||
'mime' => 'image/jpeg',
|
|
||||||
'name' => 'camera-frame.jpg',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(200)->assertSuccess();
|
|
||||||
expect($response->data())->toMatchArray([
|
|
||||||
'success' => true,
|
|
||||||
'license_plate_number' => 'AB12345',
|
|
||||||
]);
|
|
||||||
expect($response->headers['server-timing'] ?? '')
|
|
||||||
->toContain('lpr_client_capture')
|
|
||||||
->toContain('lpr_client_frame_width')
|
|
||||||
->toContain('lpr_client_frame_height')
|
|
||||||
->toContain('lpr_client_frame_bytes')
|
|
||||||
->toContain('lpr_local')
|
|
||||||
->toContain('lpr_payload')
|
|
||||||
->toContain('lpr_upstream_dns')
|
|
||||||
->toContain('lpr_upstream_connect')
|
|
||||||
->toContain('lpr_upstream')
|
|
||||||
->toContain('lpr_upstream_total')
|
|
||||||
->toContain('lpr_upstream_processing')
|
|
||||||
->toContain('lpr_total')
|
|
||||||
->toContain('lpr_route_total')
|
|
||||||
->toContain('lpr_request_total');
|
|
||||||
|
|
||||||
$capture = $fakePlateRecognizer->capture();
|
|
||||||
expect($capture['method'] ?? null)->toBe('POST');
|
|
||||||
expect($capture['authorization'] ?? null)->toBe('Token scanner-api-test-key');
|
|
||||||
expect($capture['expect'] ?? null)->toBeNull();
|
|
||||||
expect(json_decode((string)($capture['post']['config'] ?? ''), true))->toBe([
|
|
||||||
'mode' => 'fast',
|
|
||||||
'plates_per_vehicle' => 1,
|
|
||||||
'zoom_in_vehicles' => 0,
|
|
||||||
]);
|
|
||||||
expect($capture['post']['regions'] ?? null)->toBe('dk,de,se,no');
|
|
||||||
expect($capture['post']['client_capture_ms'] ?? null)->toBeNull();
|
|
||||||
expect($capture['post']['client_frame_width'] ?? null)->toBeNull();
|
|
||||||
expect($capture['post']['client_frame_height'] ?? null)->toBeNull();
|
|
||||||
expect($capture['post']['client_frame_bytes'] ?? null)->toBeNull();
|
|
||||||
expect($capture['files']['upload'] ?? [])->toMatchArray([
|
|
||||||
'name' => 'license-plate.jpg',
|
|
||||||
'type' => 'image/jpeg',
|
|
||||||
'size' => strlen('jpeg-camera-bytes'),
|
|
||||||
'error' => UPLOAD_ERR_OK,
|
|
||||||
'contents' => base64_encode('jpeg-camera-bytes'),
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
@unlink($imagePath);
|
|
||||||
$fakePlateRecognizer->stop();
|
|
||||||
scanner_lpr_api_set_env('PLATE_RECOGNIZER_API_URL', $previousPlateRecognizerUrl);
|
|
||||||
api_test_runtime()->restartServer();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
final class ScannerLprFakePlateRecognizerServer
|
|
||||||
{
|
|
||||||
private mixed $process = null;
|
|
||||||
|
|
||||||
private function __construct(
|
|
||||||
private readonly string $directory,
|
|
||||||
private readonly string $capturePath,
|
|
||||||
private readonly int $port,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function start(): self
|
|
||||||
{
|
|
||||||
$directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'scanner-lpr-fake-' . bin2hex(random_bytes(6));
|
|
||||||
if (!mkdir($directory, 0777, true) && !is_dir($directory)) {
|
|
||||||
throw new RuntimeException('Unable to create fake Plate Recognizer directory.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$capturePath = $directory . DIRECTORY_SEPARATOR . 'capture.json';
|
|
||||||
$routerPath = $directory . DIRECTORY_SEPARATOR . 'router.php';
|
|
||||||
file_put_contents($routerPath, <<<'PHP'
|
|
||||||
<?php
|
|
||||||
|
|
||||||
$path = parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?: '/';
|
|
||||||
if ($path === '/health') {
|
|
||||||
header('Content-Type: text/plain');
|
|
||||||
echo 'ok';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($path !== '/v1/plate-reader/') {
|
|
||||||
http_response_code(404);
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode(['error' => 'not found']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$upload = $_FILES['upload'] ?? [];
|
|
||||||
$tmpName = is_array($upload) ? (string)($upload['tmp_name'] ?? '') : '';
|
|
||||||
$capture = [
|
|
||||||
'method' => $_SERVER['REQUEST_METHOD'] ?? null,
|
|
||||||
'authorization' => $_SERVER['HTTP_AUTHORIZATION'] ?? null,
|
|
||||||
'expect' => $_SERVER['HTTP_EXPECT'] ?? null,
|
|
||||||
'post' => $_POST,
|
|
||||||
'files' => [
|
|
||||||
'upload' => [
|
|
||||||
'name' => is_array($upload) ? ($upload['name'] ?? null) : null,
|
|
||||||
'type' => is_array($upload) ? ($upload['type'] ?? null) : null,
|
|
||||||
'size' => is_array($upload) ? ($upload['size'] ?? null) : null,
|
|
||||||
'error' => is_array($upload) ? ($upload['error'] ?? null) : null,
|
|
||||||
'contents' => $tmpName !== '' && is_file($tmpName) ? base64_encode((string)file_get_contents($tmpName)) : null,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
file_put_contents((string)getenv('SCANNER_LPR_FAKE_CAPTURE_PATH'), json_encode($capture, JSON_UNESCAPED_SLASHES));
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode([
|
|
||||||
'processing_time' => 58.184,
|
|
||||||
'results' => [['plate' => 'ab12345', 'score' => 0.98]],
|
|
||||||
], JSON_UNESCAPED_SLASHES);
|
|
||||||
PHP);
|
|
||||||
|
|
||||||
$port = ApiServer::findAvailablePort('127.0.0.1');
|
|
||||||
$server = new self($directory, $capturePath, $port);
|
|
||||||
$server->startProcess($routerPath);
|
|
||||||
$server->waitUntilReady();
|
|
||||||
|
|
||||||
return $server;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function url(): string
|
|
||||||
{
|
|
||||||
return sprintf('http://127.0.0.1:%d', $this->port);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function capture(): array
|
|
||||||
{
|
|
||||||
$contents = is_file($this->capturePath) ? file_get_contents($this->capturePath) : false;
|
|
||||||
expect($contents)->not->toBeFalse();
|
|
||||||
|
|
||||||
$decoded = json_decode((string)$contents, true);
|
|
||||||
expect($decoded)->toBeArray();
|
|
||||||
|
|
||||||
return $decoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stop(): void
|
|
||||||
{
|
|
||||||
if (is_resource($this->process)) {
|
|
||||||
proc_terminate($this->process);
|
|
||||||
usleep(250000);
|
|
||||||
$status = proc_get_status($this->process);
|
|
||||||
if (($status['running'] ?? false) && function_exists('posix_kill')) {
|
|
||||||
@posix_kill((int)$status['pid'], 9);
|
|
||||||
}
|
|
||||||
proc_close($this->process);
|
|
||||||
$this->process = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->removeDirectory($this->directory);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function startProcess(string $routerPath): void
|
|
||||||
{
|
|
||||||
$command = sprintf(
|
|
||||||
'%s -S %s %s',
|
|
||||||
escapeshellarg((string)(PHP_BINARY ?: 'php')),
|
|
||||||
escapeshellarg('127.0.0.1:' . $this->port),
|
|
||||||
escapeshellarg($routerPath),
|
|
||||||
);
|
|
||||||
$environment = array_merge(getenv() ?: [], [
|
|
||||||
'SCANNER_LPR_FAKE_CAPTURE_PATH' => $this->capturePath,
|
|
||||||
]);
|
|
||||||
$descriptorSpec = [
|
|
||||||
0 => ['pipe', 'r'],
|
|
||||||
1 => ['file', $this->directory . DIRECTORY_SEPARATOR . 'stdout.log', 'a'],
|
|
||||||
2 => ['file', $this->directory . DIRECTORY_SEPARATOR . 'stderr.log', 'a'],
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->process = proc_open($command, $descriptorSpec, $pipes, $this->directory, $environment);
|
|
||||||
if (!is_resource($this->process)) {
|
|
||||||
throw new RuntimeException('Unable to start fake Plate Recognizer server.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($pipes[0]) && is_resource($pipes[0])) {
|
|
||||||
fclose($pipes[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function waitUntilReady(): void
|
|
||||||
{
|
|
||||||
$deadline = microtime(true) + 5;
|
|
||||||
$lastError = 'Timed out waiting for fake Plate Recognizer.';
|
|
||||||
|
|
||||||
while (microtime(true) < $deadline) {
|
|
||||||
$curl = curl_init($this->url() . '/health');
|
|
||||||
if ($curl === false) {
|
|
||||||
throw new RuntimeException('Unable to initialize fake Plate Recognizer health check.');
|
|
||||||
}
|
|
||||||
|
|
||||||
curl_setopt_array($curl, [
|
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
|
||||||
CURLOPT_CONNECTTIMEOUT_MS => 250,
|
|
||||||
CURLOPT_TIMEOUT_MS => 500,
|
|
||||||
]);
|
|
||||||
$body = curl_exec($curl);
|
|
||||||
$status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
|
||||||
if ($body === false) {
|
|
||||||
$lastError = curl_error($curl);
|
|
||||||
}
|
|
||||||
curl_close($curl);
|
|
||||||
|
|
||||||
if ($status === 200 && $body === 'ok') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
usleep(100000);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new RuntimeException($lastError);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function removeDirectory(string $directory): void
|
|
||||||
{
|
|
||||||
if (!is_dir($directory)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (glob($directory . DIRECTORY_SEPARATOR . '*') ?: [] as $path) {
|
|
||||||
if (is_file($path)) {
|
|
||||||
@unlink($path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@rmdir($directory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scanner_lpr_api_get_env(string $key): ?string
|
|
||||||
{
|
|
||||||
$value = getenv($key);
|
|
||||||
|
|
||||||
return $value === false ? null : (string)$value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scanner_lpr_api_set_env(string $key, ?string $value): void
|
|
||||||
{
|
|
||||||
if ($value === null) {
|
|
||||||
putenv($key);
|
|
||||||
unset($_ENV[$key], $_SERVER[$key]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
putenv($key . '=' . $value);
|
|
||||||
$_ENV[$key] = $value;
|
|
||||||
$_SERVER[$key] = $value;
|
|
||||||
}
|
|
||||||
@@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use classes\email;
|
|
||||||
use classes\pdf_store;
|
|
||||||
|
|
||||||
putenv('EMAIL_FAKE_MODE=1');
|
putenv('EMAIL_FAKE_MODE=1');
|
||||||
|
|
||||||
usesApiSuite();
|
usesApiSuite();
|
||||||
@@ -79,97 +76,3 @@ it('requires department access when resending order booking confirmations', func
|
|||||||
->assertSuccess(false)
|
->assertSuccess(false)
|
||||||
->assertMissingPermissions(['department_access_' . $department['id']]);
|
->assertMissingPermissions(['department_access_' . $department['id']]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows department admins to resend order booking completion confirmations', function (): void {
|
|
||||||
email::resetFakeDeliveries();
|
|
||||||
|
|
||||||
$customer = api_fixtures()->createUser([
|
|
||||||
'display_name' => 'Resend Completion Confirmation Customer',
|
|
||||||
'email' => 'resend-completion-confirmation@example.test',
|
|
||||||
]);
|
|
||||||
$department = api_fixtures()->createDepartment([
|
|
||||||
'name' => 'Resend Completion Confirmation Department',
|
|
||||||
]);
|
|
||||||
$cashier = api_fixtures()->createUser([
|
|
||||||
'display_name' => 'Resend Completion Confirmation 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'],
|
|
||||||
'reference' => 'RESEND-COMPLETION',
|
|
||||||
'reg_1' => 'DONE1',
|
|
||||||
'order_id' => $order['id'],
|
|
||||||
]);
|
|
||||||
api_fixtures()->createOrderAttachment([
|
|
||||||
'order_id' => $order['id'],
|
|
||||||
'content' => json_encode([
|
|
||||||
'document' => 'completion-confirmation-test.pdf',
|
|
||||||
'other' => 'wash_certificate',
|
|
||||||
], JSON_THROW_ON_ERROR),
|
|
||||||
]);
|
|
||||||
(new pdf_store())->createObject('completion-confirmation-test.pdf', '%PDF-1.4 test completion confirmation');
|
|
||||||
$session = api_fixtures()->createUserSession([
|
|
||||||
'complete_bookings',
|
|
||||||
'department_access_' . $department['id'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = api_client()->post('/order-bookings/completion-confirmation/resend', [
|
|
||||||
'id' => $booking['id'],
|
|
||||||
], $session['headers']);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertEnvelope()
|
|
||||||
->assertSuccess();
|
|
||||||
|
|
||||||
expect($response->data())
|
|
||||||
->toBeArray()
|
|
||||||
->toHaveKey('message', 'Completion confirmation resent successfully.')
|
|
||||||
->toHaveKey('booking')
|
|
||||||
->and($response->data()['booking'])
|
|
||||||
->toBeArray()
|
|
||||||
->and($response->data()['booking']['id'] ?? null)
|
|
||||||
->toBe($booking['id'])
|
|
||||||
->and(email::$fake_deliveries)
|
|
||||||
->toHaveCount(1)
|
|
||||||
->and(email::$fake_deliveries[0]['to'] ?? null)
|
|
||||||
->toBe('resend-completion-confirmation@example.test')
|
|
||||||
->and(email::$fake_deliveries[0]['subject'] ?? '')
|
|
||||||
->toContain('RESEND-COMPLETION');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requires department access when resending order booking completion confirmations', function (): void {
|
|
||||||
$customer = api_fixtures()->createUser([
|
|
||||||
'display_name' => 'Resend Completion Confirmation Foreign Customer',
|
|
||||||
]);
|
|
||||||
$department = api_fixtures()->createDepartment([
|
|
||||||
'name' => 'Resend Completion Confirmation Foreign Department',
|
|
||||||
]);
|
|
||||||
$order = api_fixtures()->createOrder([
|
|
||||||
'customer_id' => $customer['customer_number'],
|
|
||||||
'department_id' => $department['id'],
|
|
||||||
]);
|
|
||||||
$booking = api_fixtures()->createOrderBooking([
|
|
||||||
'customer_number' => $customer['customer_number'],
|
|
||||||
'department' => $department['id'],
|
|
||||||
'order_id' => $order['id'],
|
|
||||||
]);
|
|
||||||
$session = api_fixtures()->createUserSession([
|
|
||||||
'complete_bookings',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = api_client()->post('/order-bookings/completion-confirmation/resend', [
|
|
||||||
'id' => $booking['id'],
|
|
||||||
], $session['headers']);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertStatus(403)
|
|
||||||
->assertEnvelope()
|
|
||||||
->assertSuccess(false)
|
|
||||||
->assertMissingPermissions(['department_access_' . $department['id']]);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,513 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
usesApiSuite();
|
|
||||||
|
|
||||||
function selfserve_customer_start_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());
|
|
||||||
}
|
|
||||||
|
|
||||||
function selfserve_customer_start_make_available(int $laneId): void
|
|
||||||
{
|
|
||||||
selfserve_customer_start_ensure_legacy_redis_constant();
|
|
||||||
|
|
||||||
$lane = (new \classes\selfserve())->lane($laneId);
|
|
||||||
$lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::AVAILABLE);
|
|
||||||
$lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::IDLE);
|
|
||||||
$lane->setCustomerNumber(0);
|
|
||||||
$lane->setLicensePlate('');
|
|
||||||
$lane->setWashStartTime(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('allows the customer self-serve start sequence without department access', function (): void {
|
|
||||||
$group = api_fixtures()->createGroup([], [
|
|
||||||
'list_own_department_selfserve_vehicle_conditions',
|
|
||||||
]);
|
|
||||||
$scenario = api_fixtures()->createSelfServeScenario([
|
|
||||||
'customer' => ['group_id' => $group['id']],
|
|
||||||
'department_selfserve_enabled' => true,
|
|
||||||
'lane_selfserve_enabled' => true,
|
|
||||||
]);
|
|
||||||
$headers = api_fixtures()->bearerHeaders(
|
|
||||||
api_fixtures()->createAuthToken((int)$scenario['customer']['id'])
|
|
||||||
);
|
|
||||||
$laneId = (int)$scenario['lane']['id'];
|
|
||||||
$reg = (string)$scenario['vehicle']['reg'];
|
|
||||||
|
|
||||||
selfserve_customer_start_make_available($laneId);
|
|
||||||
|
|
||||||
api_client()
|
|
||||||
->get('/department/selfserve/vehicle/allowed?lane_id=' . $laneId . '®=' . urlencode($reg), $headers)
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertSuccess(true);
|
|
||||||
|
|
||||||
$summaryResponse = api_client()
|
|
||||||
->get('/department/selfserve/washes/summary?lane_id=' . $laneId . '®=' . urlencode($reg), $headers)
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertSuccess(true);
|
|
||||||
|
|
||||||
expect($summaryResponse->data()['session']['customer_number'] ?? null)
|
|
||||||
->toBe((int)$scenario['customer']['customer_number']);
|
|
||||||
|
|
||||||
api_client()
|
|
||||||
->post('/modules/self-serve/lane/services/allowed', [
|
|
||||||
'lane_id' => $laneId,
|
|
||||||
'task_ids' => [(int)$scenario['tasks'][1]['id']],
|
|
||||||
], $headers)
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertSuccess(true);
|
|
||||||
|
|
||||||
$startResponse = api_client()
|
|
||||||
->post('/modules/self-serve/lane/command', [
|
|
||||||
'lane_id' => $laneId,
|
|
||||||
'command' => 'START',
|
|
||||||
'license_plate' => $reg,
|
|
||||||
'wash_type' => 'Manual',
|
|
||||||
'defer_relay_side_effects' => true,
|
|
||||||
], $headers)
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertSuccess(true);
|
|
||||||
|
|
||||||
expect($startResponse->data()['status'] ?? null)->toBe('OCCUPIED')
|
|
||||||
->and($startResponse->data()['customer_number'] ?? null)
|
|
||||||
->toBe((int)$scenario['customer']['customer_number']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks the active customer session relay-enabled after machine relay enable', function (): void {
|
|
||||||
$group = api_fixtures()->createGroup([], [
|
|
||||||
'list_own_department_selfserve_vehicle_conditions',
|
|
||||||
'modules_selfserve_lane_relay_enable_machine',
|
|
||||||
]);
|
|
||||||
$scenario = api_fixtures()->createSelfServeScenario([
|
|
||||||
'customer' => ['group_id' => $group['id']],
|
|
||||||
'department_selfserve_enabled' => true,
|
|
||||||
'lane_selfserve_enabled' => true,
|
|
||||||
'session' => [
|
|
||||||
'status' => 'READY_FOR_MACHINE_START',
|
|
||||||
'machine_relay_enabled' => 0,
|
|
||||||
'machine_relay_enabled_at' => null,
|
|
||||||
'machine_start_triggered' => 0,
|
|
||||||
'machine_start_triggered_at' => null,
|
|
||||||
'wash_started_at' => null,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
$headers = api_fixtures()->bearerHeaders(
|
|
||||||
api_fixtures()->createAuthToken((int)$scenario['customer']['id'])
|
|
||||||
);
|
|
||||||
$laneId = (int)$scenario['lane']['id'];
|
|
||||||
$reg = (string)$scenario['vehicle']['reg'];
|
|
||||||
$machineTaskId = (int)$scenario['tasks'][1]['id'];
|
|
||||||
|
|
||||||
selfserve_customer_start_make_available($laneId);
|
|
||||||
|
|
||||||
api_client()
|
|
||||||
->get('/department/selfserve/vehicle/allowed?lane_id=' . $laneId . '®=' . urlencode($reg), $headers)
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertSuccess(true);
|
|
||||||
|
|
||||||
api_client()
|
|
||||||
->post('/modules/self-serve/lane/services/allowed', [
|
|
||||||
'lane_id' => $laneId,
|
|
||||||
'task_ids' => [$machineTaskId],
|
|
||||||
], $headers)
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertSuccess(true);
|
|
||||||
|
|
||||||
api_client()
|
|
||||||
->post('/modules/self-serve/lane/command', [
|
|
||||||
'lane_id' => $laneId,
|
|
||||||
'command' => 'START',
|
|
||||||
'license_plate' => $reg,
|
|
||||||
'wash_type' => 'Machine',
|
|
||||||
'defer_relay_side_effects' => true,
|
|
||||||
], $headers)
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertSuccess(true);
|
|
||||||
|
|
||||||
api_client()
|
|
||||||
->post('/modules/self-serve/lane/relay/machine/enable', [
|
|
||||||
'lane_id' => $laneId,
|
|
||||||
], $headers)
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertSuccess(true);
|
|
||||||
|
|
||||||
$activeResponse = api_client()
|
|
||||||
->get('/modules/self-serve/lane/wash/my-active-wash', $headers)
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertSuccess(true);
|
|
||||||
|
|
||||||
expect($activeResponse->data()['session']['status'] ?? null)->toBe('MACHINE_RELAY_ENABLED')
|
|
||||||
->and($activeResponse->data()['session']['machine_relay_enabled'] ?? null)->toBeTrue()
|
|
||||||
->and($activeResponse->data()['session']['machine_start_triggered'] ?? null)->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('derives allowed services from v2 session task snapshots when task rows are not legacy records', function (): void {
|
|
||||||
$group = api_fixtures()->createGroup([], [
|
|
||||||
'list_own_department_selfserve_vehicle_conditions',
|
|
||||||
]);
|
|
||||||
$scenario = api_fixtures()->createSelfServeScenario([
|
|
||||||
'customer' => ['group_id' => $group['id']],
|
|
||||||
'department_selfserve_enabled' => true,
|
|
||||||
'lane_selfserve_enabled' => true,
|
|
||||||
]);
|
|
||||||
$headers = api_fixtures()->bearerHeaders(
|
|
||||||
api_fixtures()->createAuthToken((int)$scenario['customer']['id'])
|
|
||||||
);
|
|
||||||
$laneId = (int)$scenario['lane']['id'];
|
|
||||||
$v2TaskId = 900000 + $laneId;
|
|
||||||
|
|
||||||
$sessionTask = (new \objects\selfserve_wash_session_tasks_o())->addSnapshot(
|
|
||||||
(int)$scenario['session']['id'],
|
|
||||||
$v2TaskId,
|
|
||||||
'V2 path editor machine task',
|
|
||||||
'Task exists only in the session snapshot.',
|
|
||||||
['MACHINE', 'PROGRAM_PICKER'],
|
|
||||||
['program_picker', 6, 'start'],
|
|
||||||
(int)$scenario['product']['id'],
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response = api_client()
|
|
||||||
->post('/modules/self-serve/lane/services/allowed', [
|
|
||||||
'lane_id' => $laneId,
|
|
||||||
'task_ids' => [$v2TaskId],
|
|
||||||
], $headers)
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertSuccess(true);
|
|
||||||
|
|
||||||
expect($response->data()['allowed_services'] ?? [])->toBe([
|
|
||||||
'MACHINE',
|
|
||||||
'PROGRAM_PICKER',
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
$sessionTask->delete();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('derives allowed services from published v2 config task snapshots when no session exists yet', function (): void {
|
|
||||||
$group = api_fixtures()->createGroup([], [
|
|
||||||
'list_own_department_selfserve_vehicle_conditions',
|
|
||||||
]);
|
|
||||||
$scenario = api_fixtures()->createSelfServeScenario([
|
|
||||||
'customer' => ['group_id' => $group['id']],
|
|
||||||
'department_selfserve_enabled' => true,
|
|
||||||
'lane_selfserve_enabled' => true,
|
|
||||||
'session' => [
|
|
||||||
'status' => 'COMPLETED',
|
|
||||||
'completed_at' => date('Y-m-d H:i:s'),
|
|
||||||
'wash_started_at' => date('Y-m-d H:i:s'),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
$headers = api_fixtures()->bearerHeaders(
|
|
||||||
api_fixtures()->createAuthToken((int)$scenario['customer']['id'])
|
|
||||||
);
|
|
||||||
$laneId = (int)$scenario['lane']['id'];
|
|
||||||
$departmentId = (int)$scenario['department']['id'];
|
|
||||||
$productId = (int)$scenario['product']['id'];
|
|
||||||
$v2TaskId = 910000 + $laneId;
|
|
||||||
|
|
||||||
$configVersion = (new \objects\selfserve_config_versions_o())->add(
|
|
||||||
$departmentId,
|
|
||||||
\modules\selfserve\classes\selfserve_config_versioning::STATUS_PUBLISHED,
|
|
||||||
1,
|
|
||||||
[
|
|
||||||
'schema_version' => 2,
|
|
||||||
'tasks' => [
|
|
||||||
[
|
|
||||||
'id' => $v2TaskId,
|
|
||||||
'department' => $departmentId,
|
|
||||||
'lane' => $laneId,
|
|
||||||
'product' => $productId,
|
|
||||||
'machine_type_id' => 0,
|
|
||||||
'task' => 'Published v2 machine task',
|
|
||||||
'description' => 'Task exists only in the published config snapshot.',
|
|
||||||
'order_priority' => 10,
|
|
||||||
'services' => ['MACHINE', 'PROGRAM_PICKER'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
date('Y-m-d H:i:s'),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response = api_client()
|
|
||||||
->post('/modules/self-serve/lane/services/allowed', [
|
|
||||||
'lane_id' => $laneId,
|
|
||||||
'task_ids' => [$v2TaskId],
|
|
||||||
], $headers)
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertSuccess(true);
|
|
||||||
|
|
||||||
expect($response->data()['allowed_services'] ?? [])->toBe([
|
|
||||||
'MACHINE',
|
|
||||||
'PROGRAM_PICKER',
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
$configVersion->delete();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps long generated task descriptions when refreshing vehicle eligibility snapshots', function (): void {
|
|
||||||
$group = api_fixtures()->createGroup([], [
|
|
||||||
'list_own_department_selfserve_vehicle_conditions',
|
|
||||||
]);
|
|
||||||
$scenario = api_fixtures()->createSelfServeScenario([
|
|
||||||
'customer' => ['group_id' => $group['id']],
|
|
||||||
'department_selfserve_enabled' => true,
|
|
||||||
'lane_selfserve_enabled' => true,
|
|
||||||
]);
|
|
||||||
$headers = api_fixtures()->bearerHeaders(
|
|
||||||
api_fixtures()->createAuthToken((int)$scenario['customer']['id'])
|
|
||||||
);
|
|
||||||
$laneId = (int)$scenario['lane']['id'];
|
|
||||||
$reg = (string)$scenario['vehicle']['reg'];
|
|
||||||
$productId = (int)$scenario['product']['id'];
|
|
||||||
$taskId = (int)$scenario['tasks'][1]['id'];
|
|
||||||
$longDescription = str_repeat('Hvis ja - vaelg tagboerste program (#6) og lift program (#2) / ', 6);
|
|
||||||
|
|
||||||
$statement = api_test_runtime()->db()->prepare(
|
|
||||||
'UPDATE department_selfserve_tasks SET description = ? WHERE id = ?'
|
|
||||||
);
|
|
||||||
$statement->bind_param('si', $longDescription, $taskId);
|
|
||||||
$statement->execute();
|
|
||||||
|
|
||||||
api_client()
|
|
||||||
->get(
|
|
||||||
'/department/selfserve/vehicle/allowed?lane_id=' . $laneId
|
|
||||||
. '®=' . urlencode($reg)
|
|
||||||
. '&vehicle_type=' . $productId,
|
|
||||||
$headers
|
|
||||||
)
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertSuccess(true);
|
|
||||||
|
|
||||||
$sessionTask = api_test_runtime()->queryOne(
|
|
||||||
'SELECT description FROM selfserve_wash_session_tasks'
|
|
||||||
. ' WHERE session_id = ' . (int)$scenario['session']['id']
|
|
||||||
. ' AND task_id = ' . $taskId
|
|
||||||
. ' LIMIT 1'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($sessionTask['description'] ?? null)->toBe($longDescription);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not create an active wash preview session from read-only eligibility checks', function (): void {
|
|
||||||
$group = api_fixtures()->createGroup([], [
|
|
||||||
'list_own_department_selfserve_vehicle_conditions',
|
|
||||||
]);
|
|
||||||
$scenario = api_fixtures()->createSelfServeScenario([
|
|
||||||
'customer' => ['group_id' => $group['id']],
|
|
||||||
'department_selfserve_enabled' => true,
|
|
||||||
'lane_selfserve_enabled' => true,
|
|
||||||
]);
|
|
||||||
$headers = api_fixtures()->bearerHeaders(
|
|
||||||
api_fixtures()->createAuthToken((int)$scenario['customer']['id'])
|
|
||||||
);
|
|
||||||
$laneId = (int)$scenario['lane']['id'];
|
|
||||||
$reg = (string)$scenario['vehicle']['reg'];
|
|
||||||
$productId = (int)$scenario['product']['id'];
|
|
||||||
$sessionId = (int)$scenario['session']['id'];
|
|
||||||
|
|
||||||
api_test_runtime()->db()->query(
|
|
||||||
'DELETE FROM selfserve_wash_session_events WHERE session_id = ' . $sessionId
|
|
||||||
);
|
|
||||||
api_test_runtime()->db()->query(
|
|
||||||
'DELETE FROM selfserve_wash_session_tasks WHERE session_id = ' . $sessionId
|
|
||||||
);
|
|
||||||
api_test_runtime()->db()->query(
|
|
||||||
'DELETE FROM selfserve_wash_session_answers WHERE session_id = ' . $sessionId
|
|
||||||
);
|
|
||||||
api_test_runtime()->db()->query(
|
|
||||||
'DELETE FROM selfserve_wash_sessions WHERE id = ' . $sessionId
|
|
||||||
);
|
|
||||||
|
|
||||||
selfserve_customer_start_make_available($laneId);
|
|
||||||
|
|
||||||
api_client()
|
|
||||||
->get(
|
|
||||||
'/department/selfserve/vehicle/allowed?lane_id=' . $laneId
|
|
||||||
. '®=' . urlencode($reg)
|
|
||||||
. '&vehicle_type=' . $productId,
|
|
||||||
$headers
|
|
||||||
)
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertSuccess(true);
|
|
||||||
|
|
||||||
$openSession = api_test_runtime()->queryOne(
|
|
||||||
'SELECT id FROM selfserve_wash_sessions'
|
|
||||||
. ' WHERE lane_id = ' . $laneId
|
|
||||||
. ' AND reg = "' . api_test_runtime()->db()->real_escape_string($reg) . '"'
|
|
||||||
. ' AND completed_at IS NULL'
|
|
||||||
. ' AND deleted_at IS NULL'
|
|
||||||
. ' LIMIT 1'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($openSession)->toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates a durable wash session from customer start after read-only eligibility', function (): void {
|
|
||||||
$group = api_fixtures()->createGroup([], [
|
|
||||||
'list_own_department_selfserve_vehicle_conditions',
|
|
||||||
]);
|
|
||||||
$scenario = api_fixtures()->createSelfServeScenario([
|
|
||||||
'customer' => ['group_id' => $group['id']],
|
|
||||||
'department_selfserve_enabled' => true,
|
|
||||||
'lane_selfserve_enabled' => true,
|
|
||||||
]);
|
|
||||||
$headers = api_fixtures()->bearerHeaders(
|
|
||||||
api_fixtures()->createAuthToken((int)$scenario['customer']['id'])
|
|
||||||
);
|
|
||||||
$laneId = (int)$scenario['lane']['id'];
|
|
||||||
$reg = (string)$scenario['vehicle']['reg'];
|
|
||||||
$productId = (int)$scenario['product']['id'];
|
|
||||||
$sessionId = (int)$scenario['session']['id'];
|
|
||||||
|
|
||||||
api_test_runtime()->db()->query(
|
|
||||||
'DELETE FROM selfserve_wash_session_events WHERE session_id = ' . $sessionId
|
|
||||||
);
|
|
||||||
api_test_runtime()->db()->query(
|
|
||||||
'DELETE FROM selfserve_wash_session_tasks WHERE session_id = ' . $sessionId
|
|
||||||
);
|
|
||||||
api_test_runtime()->db()->query(
|
|
||||||
'DELETE FROM selfserve_wash_session_answers WHERE session_id = ' . $sessionId
|
|
||||||
);
|
|
||||||
api_test_runtime()->db()->query(
|
|
||||||
'DELETE FROM selfserve_wash_sessions WHERE id = ' . $sessionId
|
|
||||||
);
|
|
||||||
|
|
||||||
selfserve_customer_start_make_available($laneId);
|
|
||||||
|
|
||||||
api_client()
|
|
||||||
->get(
|
|
||||||
'/department/selfserve/vehicle/allowed?lane_id=' . $laneId
|
|
||||||
. '®=' . urlencode($reg)
|
|
||||||
. '&vehicle_type=' . $productId,
|
|
||||||
$headers
|
|
||||||
)
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertSuccess(true);
|
|
||||||
|
|
||||||
api_client()
|
|
||||||
->post('/modules/self-serve/lane/command', [
|
|
||||||
'lane_id' => $laneId,
|
|
||||||
'command' => 'START',
|
|
||||||
'license_plate' => $reg,
|
|
||||||
'defer_relay_side_effects' => true,
|
|
||||||
], $headers)
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertSuccess(true);
|
|
||||||
|
|
||||||
$openSession = api_test_runtime()->queryOne(
|
|
||||||
'SELECT id, status, customer_number FROM selfserve_wash_sessions'
|
|
||||||
. ' WHERE lane_id = ' . $laneId
|
|
||||||
. ' AND reg = "' . api_test_runtime()->db()->real_escape_string($reg) . '"'
|
|
||||||
. ' AND completed_at IS NULL'
|
|
||||||
. ' AND deleted_at IS NULL'
|
|
||||||
. ' ORDER BY id DESC LIMIT 1'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($openSession)->not->toBeNull()
|
|
||||||
->and((int)$openSession['customer_number'])->toBe((int)$scenario['customer']['customer_number'])
|
|
||||||
->and($openSession['status'])->toBe('READY_FOR_MACHINE_START');
|
|
||||||
|
|
||||||
selfserve_customer_start_make_available($laneId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refreshes an active wash summary with vehicle type without a namespace error', function (): void {
|
|
||||||
$group = api_fixtures()->createGroup([], [
|
|
||||||
'list_own_department_selfserve_vehicle_conditions',
|
|
||||||
]);
|
|
||||||
$scenario = api_fixtures()->createSelfServeScenario([
|
|
||||||
'customer' => ['group_id' => $group['id']],
|
|
||||||
'department_selfserve_enabled' => true,
|
|
||||||
'lane_selfserve_enabled' => true,
|
|
||||||
'session' => [
|
|
||||||
'status' => 'MACHINE_RELAY_ENABLED',
|
|
||||||
'machine_relay_enabled' => 1,
|
|
||||||
'machine_relay_enabled_at' => date('Y-m-d H:i:s'),
|
|
||||||
'completed_at' => null,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
$headers = api_fixtures()->bearerHeaders(
|
|
||||||
api_fixtures()->createAuthToken((int)$scenario['customer']['id'])
|
|
||||||
);
|
|
||||||
$sessionId = (int)$scenario['session']['id'];
|
|
||||||
$productId = (int)$scenario['product']['id'];
|
|
||||||
|
|
||||||
$summaryResponse = api_client()
|
|
||||||
->get(
|
|
||||||
'/department/selfserve/washes/summary?session_id=' . $sessionId
|
|
||||||
. '&vehicle_type=' . $productId,
|
|
||||||
$headers
|
|
||||||
)
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertSuccess(true);
|
|
||||||
|
|
||||||
expect($summaryResponse->data()['session']['id'] ?? null)->toBe($sessionId)
|
|
||||||
->and($summaryResponse->data()['session']['status'] ?? null)->toBe('MACHINE_RELAY_ENABLED');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not create a replacement session when refreshing a completed summary with vehicle type', function (): void {
|
|
||||||
$group = api_fixtures()->createGroup([], [
|
|
||||||
'list_own_department_selfserve_vehicle_conditions',
|
|
||||||
]);
|
|
||||||
$scenario = api_fixtures()->createSelfServeScenario([
|
|
||||||
'customer' => ['group_id' => $group['id']],
|
|
||||||
'department_selfserve_enabled' => true,
|
|
||||||
'lane_selfserve_enabled' => true,
|
|
||||||
'session' => [
|
|
||||||
'status' => 'COMPLETED',
|
|
||||||
'completed_at' => date('Y-m-d H:i:s'),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
$headers = api_fixtures()->bearerHeaders(
|
|
||||||
api_fixtures()->createAuthToken((int)$scenario['customer']['id'])
|
|
||||||
);
|
|
||||||
$laneId = (int)$scenario['lane']['id'];
|
|
||||||
$reg = (string)$scenario['vehicle']['reg'];
|
|
||||||
$productId = (int)$scenario['product']['id'];
|
|
||||||
$sessionId = (int)$scenario['session']['id'];
|
|
||||||
|
|
||||||
selfserve_customer_start_make_available($laneId);
|
|
||||||
|
|
||||||
$summaryResponse = api_client()
|
|
||||||
->get(
|
|
||||||
'/department/selfserve/washes/summary?lane_id=' . $laneId
|
|
||||||
. '®=' . urlencode($reg)
|
|
||||||
. '&vehicle_type=' . $productId,
|
|
||||||
$headers
|
|
||||||
)
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertSuccess(true);
|
|
||||||
|
|
||||||
expect($summaryResponse->data()['session']['id'] ?? null)->toBe($sessionId)
|
|
||||||
->and($summaryResponse->data()['session']['status'] ?? null)->toBe('COMPLETED');
|
|
||||||
|
|
||||||
$openSession = api_test_runtime()->queryOne(
|
|
||||||
'SELECT id FROM selfserve_wash_sessions'
|
|
||||||
. ' WHERE lane_id = ' . $laneId
|
|
||||||
. ' AND reg = "' . api_test_runtime()->db()->real_escape_string($reg) . '"'
|
|
||||||
. ' AND completed_at IS NULL'
|
|
||||||
. ' AND deleted_at IS NULL'
|
|
||||||
. ' LIMIT 1'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($openSession)->toBeNull();
|
|
||||||
});
|
|
||||||
@@ -64,7 +64,6 @@ it('allows customer self-serve permission to execute START without department ac
|
|||||||
'lane_id' => (int)$scenario['lane']['id'],
|
'lane_id' => (int)$scenario['lane']['id'],
|
||||||
'command' => 'START',
|
'command' => 'START',
|
||||||
'license_plate' => (string)$scenario['vehicle']['reg'],
|
'license_plate' => (string)$scenario['vehicle']['reg'],
|
||||||
'wash_type' => 'Manual',
|
|
||||||
'defer_relay_side_effects' => true,
|
'defer_relay_side_effects' => true,
|
||||||
], api_fixtures()->bearerHeaders($token));
|
], api_fixtures()->bearerHeaders($token));
|
||||||
|
|
||||||
@@ -171,7 +170,6 @@ it('still allows elevated operators with department access to execute lane comma
|
|||||||
'lane_id' => (int)$scenario['lane']['id'],
|
'lane_id' => (int)$scenario['lane']['id'],
|
||||||
'command' => 'START',
|
'command' => 'START',
|
||||||
'license_plate' => 'OP' . (int)$scenario['lane']['id'],
|
'license_plate' => 'OP' . (int)$scenario['lane']['id'],
|
||||||
'wash_type' => 'Manual',
|
|
||||||
'defer_relay_side_effects' => true,
|
'defer_relay_side_effects' => true,
|
||||||
], $session['headers']);
|
], $session['headers']);
|
||||||
|
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use classes\email;
|
|
||||||
use objects\users_o;
|
|
||||||
|
|
||||||
putenv('EMAIL_FAKE_MODE=1');
|
|
||||||
|
|
||||||
usesApiSuite();
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
email::resetFakeDeliveries();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('persists superuser new customer notification preferences and includes them in session payloads', function (): void {
|
|
||||||
api_test_covers('PUT /account/notifications', 'happy');
|
|
||||||
|
|
||||||
$session = api_fixtures()->createUserSession([
|
|
||||||
'user_notifications_update',
|
|
||||||
'superuser',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = api_client()->put('/account/notifications', [
|
|
||||||
users_o::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS => true,
|
|
||||||
], $session['headers']);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertEnvelope()
|
|
||||||
->assertSuccess();
|
|
||||||
|
|
||||||
$key = api_test_runtime()->db()->real_escape_string(users_o::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS);
|
|
||||||
$storedPreference = api_test_runtime()->queryOne(
|
|
||||||
"SELECT val FROM user_key_value_pairs WHERE user_id = " . (int)$session['user']['id'] . " AND var = '$key'"
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($storedPreference)
|
|
||||||
->toBeArray()
|
|
||||||
->toHaveKey('val', '1');
|
|
||||||
|
|
||||||
$sessionResponse = api_client()->get('/auth/session', $session['headers']);
|
|
||||||
|
|
||||||
$sessionResponse
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertEnvelope()
|
|
||||||
->assertSuccess();
|
|
||||||
|
|
||||||
expect($sessionResponse->data()['notifications'][users_o::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS] ?? null)
|
|
||||||
->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('invalidates cached session payloads after notification preferences change', function (): void {
|
|
||||||
api_test_covers('PUT /account/notifications', 'cache');
|
|
||||||
|
|
||||||
$permissions = [
|
|
||||||
'fetch_session',
|
|
||||||
'user_notifications_update',
|
|
||||||
'superuser',
|
|
||||||
];
|
|
||||||
$session = api_fixtures()->createUserSession($permissions);
|
|
||||||
api_fixtures()->cacheAuthSessionForUser($session['user'], $session['token'], $permissions, [
|
|
||||||
'notifications' => [
|
|
||||||
users_o::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS => false,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$cachedSessionResponse = api_client()->get('/auth/session', $session['headers']);
|
|
||||||
|
|
||||||
$cachedSessionResponse
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertEnvelope()
|
|
||||||
->assertSuccess();
|
|
||||||
|
|
||||||
expect($cachedSessionResponse->data()['notifications'][users_o::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS] ?? null)
|
|
||||||
->toBeFalse();
|
|
||||||
|
|
||||||
$response = api_client()->put('/account/notifications', [
|
|
||||||
users_o::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS => true,
|
|
||||||
], $session['headers']);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertEnvelope()
|
|
||||||
->assertSuccess();
|
|
||||||
|
|
||||||
$sessionResponse = api_client()->get('/auth/session', $session['headers']);
|
|
||||||
|
|
||||||
$sessionResponse
|
|
||||||
->assertStatus(200)
|
|
||||||
->assertEnvelope()
|
|
||||||
->assertSuccess();
|
|
||||||
|
|
||||||
expect($sessionResponse->data()['notifications'][users_o::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS] ?? null)
|
|
||||||
->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requires superuser permission before saving superuser new customer notification preferences', function (): void {
|
|
||||||
api_test_covers('PUT /account/notifications', 'auth');
|
|
||||||
|
|
||||||
$session = api_fixtures()->createUserSession([
|
|
||||||
'user_notifications_update',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = api_client()->put('/account/notifications', [
|
|
||||||
users_o::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS => true,
|
|
||||||
], $session['headers']);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertStatus(403)
|
|
||||||
->assertEnvelope()
|
|
||||||
->assertSuccess(false)
|
|
||||||
->assertMissingPermissions(['superuser']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends new customer registration notifications only to opted-in superusers', function (): void {
|
|
||||||
api_test_covers('POST /auth/register/cvr', 'notification');
|
|
||||||
|
|
||||||
api_fixtures()->setModuleConfig('Email', 'mailersend_enabled', 'true', 'bool');
|
|
||||||
|
|
||||||
$customer = api_fixtures()->createUser([
|
|
||||||
'display_name' => 'Newly Registered Customer',
|
|
||||||
]);
|
|
||||||
$enabledSuperuser = api_fixtures()->createUser([
|
|
||||||
'display_name' => 'Enabled Superuser',
|
|
||||||
'email' => 'enabled-superuser@example.test',
|
|
||||||
], ['superuser']);
|
|
||||||
$regularUser = api_fixtures()->createUser([
|
|
||||||
'display_name' => 'Enabled Regular User',
|
|
||||||
'email' => 'enabled-regular@example.test',
|
|
||||||
]);
|
|
||||||
$disabledSuperuser = api_fixtures()->createUser([
|
|
||||||
'display_name' => 'Disabled Superuser',
|
|
||||||
'email' => 'disabled-superuser@example.test',
|
|
||||||
], ['superuser']);
|
|
||||||
|
|
||||||
(new users_o())
|
|
||||||
->select((int)$enabledSuperuser['id'])
|
|
||||||
->setSuperuserNewCustomerEmailNotificationsEnabled(true);
|
|
||||||
(new users_o())
|
|
||||||
->select((int)$regularUser['id'])
|
|
||||||
->setSuperuserNewCustomerEmailNotificationsEnabled(true);
|
|
||||||
(new users_o())
|
|
||||||
->select((int)$disabledSuperuser['id'])
|
|
||||||
->setSuperuserNewCustomerEmailNotificationsEnabled(false);
|
|
||||||
|
|
||||||
(new email())->sendNewCustomerRegistrationNotifications((int)$customer['customer_number']);
|
|
||||||
|
|
||||||
expect(email::$fake_deliveries)
|
|
||||||
->toHaveCount(1)
|
|
||||||
->and(email::$fake_deliveries[0]['to'] ?? null)
|
|
||||||
->toBe('enabled-superuser@example.test')
|
|
||||||
->and(email::$fake_deliveries[0]['subject'] ?? null)
|
|
||||||
->toBe('New customer registered on Truck Wash')
|
|
||||||
->and(email::$fake_deliveries[0]['html'] ?? '')
|
|
||||||
->toContain((string)$customer['customer_number']);
|
|
||||||
});
|
|
||||||
@@ -26,7 +26,6 @@ return [
|
|||||||
'GET /ping',
|
'GET /ping',
|
||||||
'PUT /order',
|
'PUT /order',
|
||||||
'GET /orders/reference-suggestions',
|
'GET /orders/reference-suggestions',
|
||||||
'POST /modules/scanner/lpr',
|
|
||||||
],
|
],
|
||||||
'happy_only_operations' => [
|
'happy_only_operations' => [
|
||||||
'GET /ping',
|
'GET /ping',
|
||||||
|
|||||||
@@ -24,28 +24,6 @@ final class ApiClient
|
|||||||
return $this->request('POST', $path, $payload, $headers);
|
return $this->request('POST', $path, $payload, $headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, scalar|null> $fields
|
|
||||||
* @param array<string, string|array{path:string,mime?:string,name?:string}> $files
|
|
||||||
* @param array<string, string> $headers
|
|
||||||
*/
|
|
||||||
public function postMultipart(string $path, array $fields = [], array $files = [], array $headers = []): ApiResponse
|
|
||||||
{
|
|
||||||
$postFields = [];
|
|
||||||
foreach ($fields as $name => $value) {
|
|
||||||
$postFields[$name] = $value === null ? '' : (string)$value;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($files as $name => $file) {
|
|
||||||
$filePath = is_array($file) ? (string)$file['path'] : (string)$file;
|
|
||||||
$mime = is_array($file) ? (string)($file['mime'] ?? 'application/octet-stream') : 'application/octet-stream';
|
|
||||||
$filename = is_array($file) ? (string)($file['name'] ?? basename($filePath)) : basename($filePath);
|
|
||||||
$postFields[$name] = new \CURLFile($filePath, $mime, $filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->requestMultipart('POST', $path, $postFields, $headers);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function put(string $path, ?array $payload = null, array $headers = []): ApiResponse
|
public function put(string $path, ?array $payload = null, array $headers = []): ApiResponse
|
||||||
{
|
{
|
||||||
return $this->request('PUT', $path, $payload, $headers);
|
return $this->request('PUT', $path, $payload, $headers);
|
||||||
@@ -118,67 +96,6 @@ final class ApiClient
|
|||||||
|
|
||||||
$decoded = json_decode($body, true);
|
$decoded = json_decode($body, true);
|
||||||
|
|
||||||
if (getenv('EMAIL_FAKE_MODE') === '1' && class_exists(\classes\email::class)) {
|
|
||||||
\classes\email::syncFakeDeliveries();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ApiResponse($status, $responseHeaders, $decoded, (string)$body);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, string|\CURLFile> $postFields
|
|
||||||
* @param array<string, string> $headers
|
|
||||||
*/
|
|
||||||
private function requestMultipart(string $method, string $path, array $postFields, array $headers = []): ApiResponse
|
|
||||||
{
|
|
||||||
$curl = curl_init();
|
|
||||||
if ($curl === false) {
|
|
||||||
throw new RuntimeException('Unable to initialize cURL for API tests.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$timeoutSeconds = (int)(getenv('API_TEST_REQUEST_TIMEOUT') ?: self::DEFAULT_TIMEOUT_SECONDS);
|
|
||||||
if ($timeoutSeconds <= 0) {
|
|
||||||
$timeoutSeconds = self::DEFAULT_TIMEOUT_SECONDS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$responseHeaders = [];
|
|
||||||
$normalizedHeaders = [];
|
|
||||||
foreach ($headers as $name => $value) {
|
|
||||||
$normalizedHeaders[] = $name . ': ' . $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
curl_setopt_array($curl, [
|
|
||||||
CURLOPT_URL => rtrim($this->baseUrl, '/') . $path,
|
|
||||||
CURLOPT_CUSTOMREQUEST => $method,
|
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
|
||||||
CURLOPT_HEADER => false,
|
|
||||||
CURLOPT_HTTPHEADER => $normalizedHeaders,
|
|
||||||
CURLOPT_CONNECTTIMEOUT => $timeoutSeconds,
|
|
||||||
CURLOPT_TIMEOUT => $timeoutSeconds,
|
|
||||||
CURLOPT_POSTFIELDS => $postFields,
|
|
||||||
CURLOPT_HEADERFUNCTION => static function ($curlHandle, string $headerLine) use (&$responseHeaders): int {
|
|
||||||
$length = strlen($headerLine);
|
|
||||||
$parts = explode(':', $headerLine, 2);
|
|
||||||
if (count($parts) === 2) {
|
|
||||||
$responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $length;
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
$body = curl_exec($curl);
|
|
||||||
if ($body === false) {
|
|
||||||
$error = curl_error($curl);
|
|
||||||
curl_close($curl);
|
|
||||||
throw new RuntimeException('API multipart request failed: ' . $error);
|
|
||||||
}
|
|
||||||
|
|
||||||
$status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
|
||||||
curl_close($curl);
|
|
||||||
|
|
||||||
$decoded = json_decode($body, true);
|
|
||||||
|
|
||||||
return new ApiResponse($status, $responseHeaders, $decoded, (string)$body);
|
return new ApiResponse($status, $responseHeaders, $decoded, (string)$body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1141,7 +1141,6 @@ final class ApiFixtures
|
|||||||
'sms_notifications_enabled' => false,
|
'sms_notifications_enabled' => false,
|
||||||
'email_notifications_enabled' => false,
|
'email_notifications_enabled' => false,
|
||||||
'wash_certificate_email' => null,
|
'wash_certificate_email' => null,
|
||||||
'superuser_new_customer_email_notifications_enabled' => false,
|
|
||||||
],
|
],
|
||||||
'created_at' => $this->now(),
|
'created_at' => $this->now(),
|
||||||
'updated_at' => $this->now(),
|
'updated_at' => $this->now(),
|
||||||
|
|||||||
@@ -81,21 +81,6 @@ final class ApiTestRuntime
|
|||||||
return new ApiClient($this->baseUrl);
|
return new ApiClient($this->baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function restartServer(): void
|
|
||||||
{
|
|
||||||
if ($this->usesExternalBaseUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->server !== null) {
|
|
||||||
$this->server->stop();
|
|
||||||
$this->server = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->baseUrl = self::DEFAULT_BASE_URL;
|
|
||||||
$this->internalServerPort = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function fixtures(): ApiFixtures
|
public function fixtures(): ApiFixtures
|
||||||
{
|
{
|
||||||
if ($this->fixtures === null) {
|
if ($this->fixtures === null) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
['path' => 'tests/auth/CreateTokenUserNotFoundTest.php', 'classification' => 'unit', 'type' => 'script'],
|
['path' => 'tests/auth/CreateTokenUserNotFoundTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||||
['path' => 'tests/auth/NewCustomerEmailTemplateTest.php', 'classification' => 'unit', 'type' => 'script'],
|
|
||||||
['path' => 'tests/auth/PasskeyChallengeTest.php', 'classification' => 'unit', 'type' => 'script'],
|
['path' => 'tests/auth/PasskeyChallengeTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||||
['path' => 'tests/auth/PemToCoseConversionTest.php', 'classification' => 'unit', 'type' => 'script'],
|
['path' => 'tests/auth/PemToCoseConversionTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||||
['path' => 'tests/auth/RegisterCvrTest.php', 'classification' => 'unit', 'type' => 'script'],
|
['path' => 'tests/auth/RegisterCvrTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use objects\customer_password_reset_keys_o;
|
|
||||||
|
|
||||||
app_require('objects/customer_password_reset_keys_o.php');
|
|
||||||
|
|
||||||
if (!class_exists('PasswordResetTokenExpiryFakeResult')) {
|
|
||||||
class PasswordResetTokenExpiryFakeResult
|
|
||||||
{
|
|
||||||
public int $num_rows;
|
|
||||||
|
|
||||||
public function __construct(private readonly array $rows)
|
|
||||||
{
|
|
||||||
$this->num_rows = count($rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function fetch_assoc(): ?array
|
|
||||||
{
|
|
||||||
return $this->rows[0] ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!class_exists('PasswordResetTokenExpiryFakeDb')) {
|
|
||||||
class PasswordResetTokenExpiryFakeDb
|
|
||||||
{
|
|
||||||
public array $queries = [];
|
|
||||||
|
|
||||||
public function __construct(private readonly array $results)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function escape_string(string $string): string
|
|
||||||
{
|
|
||||||
return addslashes($string);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function query(string $sql): PasswordResetTokenExpiryFakeResult
|
|
||||||
{
|
|
||||||
$this->queries[] = $sql;
|
|
||||||
|
|
||||||
return $this->results[count($this->queries) - 1] ?? new PasswordResetTokenExpiryFakeResult([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!class_exists('PasswordResetTokenExpiryProbe')) {
|
|
||||||
class PasswordResetTokenExpiryProbe extends customer_password_reset_keys_o
|
|
||||||
{
|
|
||||||
public function getObjectProperties(): void
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forceSelectedId(int $id): void
|
|
||||||
{
|
|
||||||
$this->id = $id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
$this->previousDb = $GLOBALS['db'] ?? null;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
if ($this->previousDb !== null) {
|
|
||||||
$GLOBALS['db'] = $this->previousDb;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
unset($GLOBALS['db']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps password reset tokens valid for 72 hours', function (): void {
|
|
||||||
expect(customer_password_reset_keys_o::TOKEN_EXPIRY_SECONDS)->toBe(72 * 60 * 60);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('looks up reset tokens using the database 72 hour validity window', function (): void {
|
|
||||||
$GLOBALS['db'] = new PasswordResetTokenExpiryFakeDb([
|
|
||||||
new PasswordResetTokenExpiryFakeResult([['id' => 42]]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$token = str_repeat('a', customer_password_reset_keys_o::TOKEN_LENGTH);
|
|
||||||
$probe = new PasswordResetTokenExpiryProbe();
|
|
||||||
|
|
||||||
$found = $probe->findValidByToken($token);
|
|
||||||
|
|
||||||
expect($found)->toBe($probe)
|
|
||||||
->and($probe->id)->toBe(42)
|
|
||||||
->and($GLOBALS['db']->queries[0])->toContain('created_at >= DATE_SUB(NOW(), INTERVAL 259200 SECOND)')
|
|
||||||
->and($GLOBALS['db']->queries[0])->not->toContain("DATE_SUB('");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the same database 72 hour window for the selected token guard', function (): void {
|
|
||||||
$GLOBALS['db'] = new PasswordResetTokenExpiryFakeDb([
|
|
||||||
new PasswordResetTokenExpiryFakeResult([['id' => 42]]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$probe = new PasswordResetTokenExpiryProbe();
|
|
||||||
$probe->forceSelectedId(42);
|
|
||||||
|
|
||||||
expect($probe->isValidToken())->toBeTrue()
|
|
||||||
->and($GLOBALS['db']->queries[0])->toContain('id = 42')
|
|
||||||
->and($GLOBALS['db']->queries[0])->toContain('created_at >= DATE_SUB(NOW(), INTERVAL 259200 SECOND)');
|
|
||||||
});
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
it('wires the order booking completion confirmation resend endpoint', function (): void {
|
|
||||||
$routeFile = app_path('routes/orderBookingRoute.php');
|
|
||||||
|
|
||||||
expect(is_file($routeFile))->toBeTrue();
|
|
||||||
|
|
||||||
$routeCode = preg_replace('/\s+/', ' ', (string)file_get_contents($routeFile));
|
|
||||||
|
|
||||||
expect($routeCode)
|
|
||||||
->toContain("$" . "this->post('/order-bookings/completion-confirmation/resend', function () {")
|
|
||||||
->toContain("self::requirePermission('complete_bookings');")
|
|
||||||
->toContain('self::requireDepartmentAccess((int)$object->department->value());')
|
|
||||||
->toContain('(new email())->sendWashCertificateEmailToCustomer($object);')
|
|
||||||
->toContain("'Completion confirmation resent successfully.'");
|
|
||||||
});
|
|
||||||
@@ -587,6 +587,12 @@ it('defines Coolify schema, route permissions, and replication integration hooks
|
|||||||
expect($schema)->toContain('65.21.214.30');
|
expect($schema)->toContain('65.21.214.30');
|
||||||
expect($schema)->toContain('23.88.23.183');
|
expect($schema)->toContain('23.88.23.183');
|
||||||
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'enabled'");
|
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'enabled'");
|
||||||
|
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'github_runner_token'");
|
||||||
|
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'github_runner_service_uuid'");
|
||||||
|
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'github_runner_frontend_repository'");
|
||||||
|
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'github_runner_backend_repository'");
|
||||||
|
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'github_runner_labels'");
|
||||||
|
expect($schema)->toContain("ensureModuleConfigDefault('Coolify', 'github_runner_count_per_repo'");
|
||||||
|
|
||||||
expect($route)->toContain('/superuser/coolify');
|
expect($route)->toContain('/superuser/coolify');
|
||||||
expect($route)->toContain('/superuser/coolify/load-balancer');
|
expect($route)->toContain('/superuser/coolify/load-balancer');
|
||||||
@@ -594,6 +600,8 @@ it('defines Coolify schema, route permissions, and replication integration hooks
|
|||||||
expect($route)->toContain('/superuser/coolify/load-balancer/routes/deploy');
|
expect($route)->toContain('/superuser/coolify/load-balancer/routes/deploy');
|
||||||
expect($route)->toContain('/superuser/coolify/load-balancer/api/deploy');
|
expect($route)->toContain('/superuser/coolify/load-balancer/api/deploy');
|
||||||
expect($route)->toContain('/superuser/coolify/gateways');
|
expect($route)->toContain('/superuser/coolify/gateways');
|
||||||
|
expect($route)->toContain('/superuser/coolify/github-runners');
|
||||||
|
expect($route)->toContain('/superuser/coolify/github-runners/deploy');
|
||||||
expect($route)->toContain('/superuser/coolify/gateways/{id}/test');
|
expect($route)->toContain('/superuser/coolify/gateways/{id}/test');
|
||||||
expect($route)->toContain('/superuser/coolify/instances/{id}/test');
|
expect($route)->toContain('/superuser/coolify/instances/{id}/test');
|
||||||
expect($route)->toContain('/superuser/coolify/instances/{id}/placement');
|
expect($route)->toContain('/superuser/coolify/instances/{id}/placement');
|
||||||
@@ -660,6 +668,8 @@ it('defines Coolify schema, route permissions, and replication integration hooks
|
|||||||
expect($manager)->toContain('reconcileLoadBalancer');
|
expect($manager)->toContain('reconcileLoadBalancer');
|
||||||
expect($manager)->toContain('deployGatewayApplicationRoutes');
|
expect($manager)->toContain('deployGatewayApplicationRoutes');
|
||||||
expect($manager)->toContain('deployGatewayApiCode');
|
expect($manager)->toContain('deployGatewayApiCode');
|
||||||
|
expect($manager)->toContain('deployGithubRunners');
|
||||||
|
expect($manager)->toContain('githubRunnerSummary');
|
||||||
expect($manager)->toContain('gateway_api_code_deploy');
|
expect($manager)->toContain('gateway_api_code_deploy');
|
||||||
expect($manager)->toContain('deploy_gateway_route_after_code');
|
expect($manager)->toContain('deploy_gateway_route_after_code');
|
||||||
expect($manager)->toContain('loadBalancerReleaseGatewayTargets');
|
expect($manager)->toContain('loadBalancerReleaseGatewayTargets');
|
||||||
@@ -723,6 +733,8 @@ it('defines Coolify schema, route permissions, and replication integration hooks
|
|||||||
expect($cron)->toContain('CoolifyLoadBalancerReconcileCron');
|
expect($cron)->toContain('CoolifyLoadBalancerReconcileCron');
|
||||||
expect($coolifyConfig)->toContain('[redacted]');
|
expect($coolifyConfig)->toContain('[redacted]');
|
||||||
expect($coolifyConfig)->toContain('secret_set');
|
expect($coolifyConfig)->toContain('secret_set');
|
||||||
|
expect($coolifyConfig)->toContain('github_runner_token');
|
||||||
|
expect($coolifyConfig)->toContain('github_runner_service_uuid');
|
||||||
expect($tokenConfig)->toContain('replication_secret_box::encrypt');
|
expect($tokenConfig)->toContain('replication_secret_box::encrypt');
|
||||||
expect($openapi)->toContain('/superuser/coolify:');
|
expect($openapi)->toContain('/superuser/coolify:');
|
||||||
expect($openapi)->toContain('operationId: getSuperuserCoolifyLoadBalancer');
|
expect($openapi)->toContain('operationId: getSuperuserCoolifyLoadBalancer');
|
||||||
@@ -730,10 +742,13 @@ it('defines Coolify schema, route permissions, and replication integration hooks
|
|||||||
expect($openapi)->toContain('operationId: deploySuperuserCoolifyGatewayRoutes');
|
expect($openapi)->toContain('operationId: deploySuperuserCoolifyGatewayRoutes');
|
||||||
expect($openapi)->toContain('operationId: deploySuperuserCoolifyGatewayApiCode');
|
expect($openapi)->toContain('operationId: deploySuperuserCoolifyGatewayApiCode');
|
||||||
expect($openapi)->toContain('operationId: listSuperuserCoolifyGateways');
|
expect($openapi)->toContain('operationId: listSuperuserCoolifyGateways');
|
||||||
|
expect($openapi)->toContain('operationId: getSuperuserCoolifyGithubRunners');
|
||||||
|
expect($openapi)->toContain('operationId: deploySuperuserCoolifyGithubRunners');
|
||||||
expect($openapi)->toContain('operationId: testSuperuserCoolifyGateway');
|
expect($openapi)->toContain('operationId: testSuperuserCoolifyGateway');
|
||||||
expect($openapi)->toContain('operationId: discoverSuperuserCoolifyInstancePlacement');
|
expect($openapi)->toContain('operationId: discoverSuperuserCoolifyInstancePlacement');
|
||||||
expect($openapi)->toContain('operationId: createSuperuserCoolifyTarget');
|
expect($openapi)->toContain('operationId: createSuperuserCoolifyTarget');
|
||||||
expect($openapi)->toContain('SuperuserCoolifyTarget');
|
expect($openapi)->toContain('SuperuserCoolifyTarget');
|
||||||
expect($openapi)->toContain('SuperuserCoolifyLoadBalancer');
|
expect($openapi)->toContain('SuperuserCoolifyLoadBalancer');
|
||||||
|
expect($openapi)->toContain('SuperuserCoolifyGithubRunnerSummary');
|
||||||
expect($openapi)->toContain('SuperuserCoolifyGateway');
|
expect($openapi)->toContain('SuperuserCoolifyGateway');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,36 +38,6 @@ namespace {
|
|||||||
expect($content)->toContain("switch (\$dynamic_image_id)");
|
expect($content)->toContain("switch (\$dynamic_image_id)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the selfserve dynamic image size config for route output and cache keys', function (): void {
|
|
||||||
$route = file_get_contents(app_path('routes/departmentLanesRoute.php'));
|
|
||||||
$config = file_get_contents(app_path('modules/selfserve/config/selfserve_dynamic_image_size_c.php'));
|
|
||||||
$moduleConfig = file_get_contents(app_path('modules/selfserve/selfserve_c.php'));
|
|
||||||
$imageTrait = file_get_contents(app_path('modules/dynamicimages/traits/dynamicimages_image_t.php'));
|
|
||||||
|
|
||||||
expect($route)->not->toBeFalse();
|
|
||||||
expect($config)->not->toBeFalse();
|
|
||||||
expect($moduleConfig)->not->toBeFalse();
|
|
||||||
expect($imageTrait)->not->toBeFalse();
|
|
||||||
expect($config)->toContain("self::SIZE_ORIGINAL");
|
|
||||||
expect($config)->toContain("self::SIZE_RELEVANT");
|
|
||||||
expect($config)->toContain("[self::SIZE_ORIGINAL, self::SIZE_RELEVANT]");
|
|
||||||
expect($config)->toContain("'dynamic_image_size'");
|
|
||||||
expect($moduleConfig)->toContain("selfserve_dynamic_image_size_c::class");
|
|
||||||
expect($moduleConfig)->toContain('public selfserve_dynamic_image_size_c $dynamic_image_size;');
|
|
||||||
expect($route)->toContain('$dynamic_image_size = self::getSelfServeDynamicImageSizeMode();');
|
|
||||||
expect($route)->toContain("'dynamic_image_size' => \$dynamic_image_size");
|
|
||||||
expect($route)->toContain('self::buildDynamicImageCacheKey');
|
|
||||||
expect($route)->toContain("'thumb_position' => \$thumb_position");
|
|
||||||
expect($route)->toContain("'buttons' => \$buttons");
|
|
||||||
expect($route)->toContain("'current_step' => \$current_step");
|
|
||||||
expect($route)->toContain("'only_current_step' => \$only_current_step");
|
|
||||||
expect($route)->toContain("'vehicle_type' => \$vehicle_type");
|
|
||||||
expect($route)->toContain("resizeToMaxWidth(self::RELEVANT_DYNAMIC_IMAGE_MAX_WIDTH)");
|
|
||||||
expect($imageTrait)->toContain('function resizeToMaxWidth(int $maxWidth)');
|
|
||||||
expect($imageTrait)->toContain('function exportBinary(?string $format = null, int $quality = 90): string');
|
|
||||||
expect($route)->toContain("\$imageData = \$image->exportBinary('png');");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts ordered dynamic image button tokens including program picker reset start and zero', function (): void {
|
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([
|
expect(department_selfserve_tasks_o::normalizeButtonsInput('["program_picker","reset",0,2,"start",5]'))->toBe([
|
||||||
'program_picker',
|
'program_picker',
|
||||||
|
|||||||
@@ -13,8 +13,4 @@ it('registers dynamic image pre-render cron task and related helpers', function
|
|||||||
expect($content)->toContain('normalizeButtonsInput');
|
expect($content)->toContain('normalizeButtonsInput');
|
||||||
expect($content)->toContain('dynamic_image:');
|
expect($content)->toContain('dynamic_image:');
|
||||||
expect($content)->toContain('machine_1');
|
expect($content)->toContain('machine_1');
|
||||||
expect($content)->toContain('getSelfServeDynamicImageSizeModeForCron');
|
|
||||||
expect($content)->toContain("'dynamic_image_size' => getSelfServeDynamicImageSizeModeForCron()");
|
|
||||||
expect($content)->toContain('resizeToMaxWidth(DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH)');
|
|
||||||
expect($content)->toContain("\$image->exportBinary('png')");
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
app_require('classes/email.php');
|
|
||||||
|
|
||||||
use classes\email;
|
|
||||||
|
|
||||||
it('syncs fake email deliveries written by another process', function (): void {
|
|
||||||
$previousFakeMode = getenv('EMAIL_FAKE_MODE');
|
|
||||||
$previousFakePath = getenv('EMAIL_FAKE_DELIVERIES_PATH');
|
|
||||||
$path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'truckwash-fake-email-sync-' . bin2hex(random_bytes(4)) . '.jsonl';
|
|
||||||
|
|
||||||
putenv('EMAIL_FAKE_MODE=1');
|
|
||||||
putenv('EMAIL_FAKE_DELIVERIES_PATH=' . $path);
|
|
||||||
email::resetFakeDeliveries();
|
|
||||||
|
|
||||||
try {
|
|
||||||
file_put_contents($path, json_encode([
|
|
||||||
'to' => 'customer@example.test',
|
|
||||||
'recipient_name' => 'Customer',
|
|
||||||
'subject' => 'Subject',
|
|
||||||
'message' => '',
|
|
||||||
'html' => '<p>Body</p>',
|
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
|
||||||
|
|
||||||
email::syncFakeDeliveries();
|
|
||||||
|
|
||||||
expect(email::$fake_deliveries)
|
|
||||||
->toHaveCount(1)
|
|
||||||
->and(email::$fake_deliveries[0]['to'] ?? null)
|
|
||||||
->toBe('customer@example.test')
|
|
||||||
->and(email::$fake_deliveries[0]['subject'] ?? null)
|
|
||||||
->toBe('Subject');
|
|
||||||
} finally {
|
|
||||||
email::resetFakeDeliveries();
|
|
||||||
if (is_file($path)) {
|
|
||||||
unlink($path);
|
|
||||||
}
|
|
||||||
if ($previousFakeMode === false) {
|
|
||||||
putenv('EMAIL_FAKE_MODE');
|
|
||||||
} else {
|
|
||||||
putenv('EMAIL_FAKE_MODE=' . $previousFakeMode);
|
|
||||||
}
|
|
||||||
if ($previousFakePath === false) {
|
|
||||||
putenv('EMAIL_FAKE_DELIVERIES_PATH');
|
|
||||||
} else {
|
|
||||||
putenv('EMAIL_FAKE_DELIVERIES_PATH=' . $previousFakePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
it('renders the new customer welcome template without unselected user access or leaked output', function (): void {
|
|
||||||
$result = run_legacy_script('tests/auth/NewCustomerEmailTemplateTest.php');
|
|
||||||
|
|
||||||
expect($result['exitCode'])->toBe(0, $result['output']);
|
|
||||||
});
|
|
||||||
@@ -30,20 +30,10 @@ it('builds credential-safe normal CORS response headers for allowed origins', fu
|
|||||||
expect($headers['Access-Control-Allow-Methods'])->toContain('PATCH');
|
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('X-Release-Trace');
|
||||||
expect($headers['Access-Control-Allow-Headers'])->toContain('Cache-Control');
|
expect($headers['Access-Control-Allow-Headers'])->toContain('Cache-Control');
|
||||||
expect($headers['Access-Control-Expose-Headers'])->toContain('Server-Timing');
|
|
||||||
expect($headers['Access-Control-Max-Age'])->toBe('86400');
|
expect($headers['Access-Control-Max-Age'])->toBe('86400');
|
||||||
expect($headers['Timing-Allow-Origin'])->toBe('http://localhost:5173');
|
|
||||||
expect($headers['Vary'])->toBe('Origin');
|
expect($headers['Vary'])->toBe('Origin');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows the fallback Vite localhost dev origin used after port 5173 is busy', function (): void {
|
|
||||||
$preflight = cors_policy::preflightResponse('http://localhost:5174', 'https://truckwash.io');
|
|
||||||
|
|
||||||
expect($preflight['allowed'])->toBeTrue();
|
|
||||||
expect($preflight['status'])->toBe(200);
|
|
||||||
expect($preflight['headers']['Access-Control-Allow-Origin'])->toBe('http://localhost:5174');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds preflight CORS response headers for api-v2 release URLs', function (): void {
|
it('builds preflight CORS response headers for api-v2 release URLs', function (): void {
|
||||||
$preflight = cors_policy::preflightResponse(
|
$preflight = cors_policy::preflightResponse(
|
||||||
'https://api-v2.truckwash.io/master/api',
|
'https://api-v2.truckwash.io/master/api',
|
||||||
@@ -54,8 +44,6 @@ it('builds preflight CORS response headers for api-v2 release URLs', function ()
|
|||||||
expect($preflight['status'])->toBe(200);
|
expect($preflight['status'])->toBe(200);
|
||||||
expect($preflight['headers']['Access-Control-Allow-Origin'])->toBe('https://api-v2.truckwash.io');
|
expect($preflight['headers']['Access-Control-Allow-Origin'])->toBe('https://api-v2.truckwash.io');
|
||||||
expect($preflight['headers']['Content-Type'])->toBe('application/json');
|
expect($preflight['headers']['Content-Type'])->toBe('application/json');
|
||||||
expect($preflight['headers']['Access-Control-Expose-Headers'])->toContain('Server-Timing');
|
|
||||||
expect($preflight['headers']['Timing-Allow-Origin'])->toBe('https://api-v2.truckwash.io');
|
|
||||||
expect($preflight['body'])->toBe('');
|
expect($preflight['body'])->toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,5 +63,4 @@ it('reflects the request origin for wildcard CORS instead of sending credentiale
|
|||||||
expect($headers['Access-Control-Allow-Origin'])->toBe('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-Credentials'])->toBe('true');
|
||||||
expect($headers['Access-Control-Allow-Origin'])->not->toBe('*');
|
expect($headers['Access-Control-Allow-Origin'])->not->toBe('*');
|
||||||
expect($headers['Timing-Allow-Origin'])->toBe('https://partner.example.test');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
function phpFpmWorkerConfigRepoRoot(): string
|
|
||||||
{
|
|
||||||
$configuredRoot = getenv('PLENO_REPO_ROOT_FOR_TESTS');
|
|
||||||
if (is_string($configuredRoot) && $configuredRoot !== '') {
|
|
||||||
return rtrim($configuredRoot, DIRECTORY_SEPARATOR);
|
|
||||||
}
|
|
||||||
|
|
||||||
$appRoot = defined('WD') ? WD : dirname(__DIR__, 3);
|
|
||||||
|
|
||||||
return dirname($appRoot, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
function phpFpmWorkerConfigRepoPath(string $relative): string
|
|
||||||
{
|
|
||||||
return phpFpmWorkerConfigRepoRoot() . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative);
|
|
||||||
}
|
|
||||||
|
|
||||||
function phpFpmWorkerConfigValue(string $poolConfig, string $key): ?int
|
|
||||||
{
|
|
||||||
if (!preg_match('/^' . preg_quote($key, '/') . '\s*=\s*(\d+)\s*$/m', $poolConfig, $matches)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int)$matches[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
it('configures PHP-FPM with multiple warm request workers', function (): void {
|
|
||||||
$poolPath = phpFpmWorkerConfigRepoPath('services/php/php-fpm-pool.conf');
|
|
||||||
expect(is_file($poolPath))->toBeTrue();
|
|
||||||
|
|
||||||
$poolConfig = (string)file_get_contents($poolPath);
|
|
||||||
|
|
||||||
expect($poolConfig)->toContain('[www]')
|
|
||||||
->and($poolConfig)->toContain('pm = dynamic')
|
|
||||||
->and(phpFpmWorkerConfigValue($poolConfig, 'pm.max_children'))->toBeGreaterThanOrEqual(8)
|
|
||||||
->and(phpFpmWorkerConfigValue($poolConfig, 'pm.start_servers'))->toBeGreaterThanOrEqual(4)
|
|
||||||
->and(phpFpmWorkerConfigValue($poolConfig, 'pm.min_spare_servers'))->toBeGreaterThanOrEqual(4)
|
|
||||||
->and(phpFpmWorkerConfigValue($poolConfig, 'pm.max_spare_servers'))->toBeGreaterThanOrEqual(8);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('copies the worker pool config into every API PHP image', function (): void {
|
|
||||||
$copyInstruction = 'COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf';
|
|
||||||
$dockerfiles = [
|
|
||||||
phpFpmWorkerConfigRepoPath('Dockerfile'),
|
|
||||||
phpFpmWorkerConfigRepoPath('Dockerfile.coolify-api'),
|
|
||||||
phpFpmWorkerConfigRepoPath('services/php/Dockerfile'),
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($dockerfiles as $dockerfile) {
|
|
||||||
expect(is_file($dockerfile))->toBeTrue();
|
|
||||||
expect((string)file_get_contents($dockerfile))->toContain($copyInstruction);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
it('wires collected invoice customer moves through the dedicated route and permission', function (): void {
|
|
||||||
$routeContent = file_get_contents(app_path('routes/orderInvoicesRoute.php'));
|
|
||||||
$objectContent = file_get_contents(app_path('objects/collected_order_invoices_o.php'));
|
|
||||||
|
|
||||||
expect($routeContent)->not->toBeFalse()
|
|
||||||
->and($routeContent)->toContain("\$this->post('/collected-invoices/move-to-customer'")
|
|
||||||
->and($routeContent)->toContain("self::requirePermission('move_collected_invoice_customer')")
|
|
||||||
->and($routeContent)->toContain('$collected_order_invoices->moveToCustomer')
|
|
||||||
->and($routeContent)->toContain("\$response->add_meta('move', \$move_result)")
|
|
||||||
->and($objectContent)->not->toBeFalse()
|
|
||||||
->and($objectContent)->toContain('public function moveToCustomer(int $target_customer_number): array')
|
|
||||||
->and($objectContent)->toContain('Invoice collections with an external or booked invoice cannot be moved')
|
|
||||||
->and($objectContent)->toContain('SELECT id FROM orders WHERE invoice_collection_id = {$invoice_collection_id}')
|
|
||||||
->and($objectContent)->toContain('$order->customer_id->set($target_customer_number)')
|
|
||||||
->and($objectContent)->toContain('$this->customer_number->set($target_customer_number)')
|
|
||||||
->and($objectContent)->toContain('$db->conn()->begin_transaction()');
|
|
||||||
});
|
|
||||||
@@ -113,7 +113,6 @@ it('normalizes period pagination options and clamps invalid page and limit value
|
|||||||
'search' => ' Nordic ',
|
'search' => ' Nordic ',
|
||||||
'includeRequiresAction' => '0',
|
'includeRequiresAction' => '0',
|
||||||
'includeBooked' => 'false',
|
'includeBooked' => 'false',
|
||||||
'flagTab' => 'invalid-tab',
|
|
||||||
]]);
|
]]);
|
||||||
|
|
||||||
expect($options)->toBe([
|
expect($options)->toBe([
|
||||||
@@ -142,12 +141,10 @@ it('normalizes period pagination options and clamps invalid page and limit value
|
|||||||
'periodView' => 'invoice_per_order',
|
'periodView' => 'invoice_per_order',
|
||||||
'page' => '3',
|
'page' => '3',
|
||||||
'limit' => '0',
|
'limit' => '0',
|
||||||
'flagTab' => 'yellow',
|
|
||||||
]]))->toMatchArray([
|
]]))->toMatchArray([
|
||||||
'periodView' => 'invoice_per_order',
|
'periodView' => 'invoice_per_order',
|
||||||
'page' => 3,
|
'page' => 3,
|
||||||
'limit' => 100,
|
'limit' => 100,
|
||||||
'flagTab' => 'yellow',
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -390,79 +387,3 @@ it('applies requires-action and booked visibility filters before counting and sl
|
|||||||
expect($result['period']['type_counts']['all']['total'])->toBe(1);
|
expect($result['period']['type_counts']['all']['total'])->toBe(1);
|
||||||
expect($result['period']['types']['all'][0]['customer_number'])->toBe(3003);
|
expect($result['period']['types']['all'][0]['customer_number'])->toBe(3003);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters period flag tabs using active customer-scoped flag counts', function (): void {
|
|
||||||
$period = [
|
|
||||||
'dateFrom' => '2026-04-01 00:00:00',
|
|
||||||
'dateTo' => '2026-04-30 23:59:59',
|
|
||||||
'types' => [
|
|
||||||
'all' => [
|
|
||||||
invoicing_period_customer_card(4001, 'Yellow Order Flag', [
|
|
||||||
invoicing_period_transaction(['customer_number' => 4001, 'id' => 41]),
|
|
||||||
], false, [
|
|
||||||
'flags' => [
|
|
||||||
[
|
|
||||||
'source' => 'automatic',
|
|
||||||
'status' => 'active',
|
|
||||||
'target_type' => 'order',
|
|
||||||
'order_id' => 41,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]),
|
|
||||||
invoicing_period_customer_card(4002, 'Ignored Manual Flag', [
|
|
||||||
invoicing_period_transaction(['customer_number' => 4002, 'id' => 42]),
|
|
||||||
], false, [
|
|
||||||
'flags' => [
|
|
||||||
[
|
|
||||||
'source' => 'manual',
|
|
||||||
'status' => 'ignored',
|
|
||||||
'target_type' => 'customer',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]),
|
|
||||||
invoicing_period_customer_card(4003, 'Red Customer Flag', [
|
|
||||||
invoicing_period_transaction(['customer_number' => 4003, 'id' => 43]),
|
|
||||||
], false, [
|
|
||||||
'flags' => [
|
|
||||||
[
|
|
||||||
'source' => 'manual',
|
|
||||||
'status' => 'active',
|
|
||||||
'target_type' => 'customer',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$yellowResult = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
|
|
||||||
'periodView' => 'all',
|
|
||||||
'page' => 1,
|
|
||||||
'limit' => 25,
|
|
||||||
'search' => '',
|
|
||||||
'flagTab' => 'yellow',
|
|
||||||
'includeRequiresAction' => true,
|
|
||||||
'includeBooked' => true,
|
|
||||||
]]);
|
|
||||||
|
|
||||||
expect($yellowResult['pagination']['total'])->toBe(1);
|
|
||||||
expect(array_column($yellowResult['period']['types']['all'], 'customer_number'))->toBe([4001]);
|
|
||||||
|
|
||||||
$noneResult = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
|
|
||||||
'periodView' => 'all',
|
|
||||||
'page' => 1,
|
|
||||||
'limit' => 25,
|
|
||||||
'search' => '',
|
|
||||||
'flagTab' => 'none',
|
|
||||||
'includeRequiresAction' => true,
|
|
||||||
'includeBooked' => true,
|
|
||||||
]]);
|
|
||||||
|
|
||||||
expect($noneResult['pagination']['total'])->toBe(1);
|
|
||||||
expect(array_column($noneResult['period']['types']['all'], 'customer_number'))->toBe([4002]);
|
|
||||||
expect($noneResult['period']['type_counts']['all'])->toMatchArray([
|
|
||||||
'manual_flags' => 1,
|
|
||||||
'automatic_flags' => 1,
|
|
||||||
'total' => 3,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -2,33 +2,9 @@
|
|||||||
|
|
||||||
app_require('classes/release_manager.php');
|
app_require('classes/release_manager.php');
|
||||||
app_require('classes/release_manager_schema_bootstrap.php');
|
app_require('classes/release_manager_schema_bootstrap.php');
|
||||||
app_require('classes/coolify_api_client.php');
|
|
||||||
|
|
||||||
use classes\coolify_api_client;
|
|
||||||
use classes\release_manager;
|
use classes\release_manager;
|
||||||
|
|
||||||
class ReleaseManagerCoolifyEnvFake extends coolify_api_client
|
|
||||||
{
|
|
||||||
public array $envRows;
|
|
||||||
public array $deleted = [];
|
|
||||||
|
|
||||||
public function __construct(array $envRows)
|
|
||||||
{
|
|
||||||
$this->envRows = $envRows;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function listApplicationEnvs(string $uuid): array
|
|
||||||
{
|
|
||||||
return $this->envRows;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteApplicationEnv(string $uuid, string $envUuid): array
|
|
||||||
{
|
|
||||||
$this->deleted[] = [$uuid, $envUuid];
|
|
||||||
return ['message' => 'deleted'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('redacts sensitive release timeline payload fields recursively', function (): void {
|
it('redacts sensitive release timeline payload fields recursively', function (): void {
|
||||||
$payload = [
|
$payload = [
|
||||||
'Authorization' => 'Bearer secret-token',
|
'Authorization' => 'Bearer secret-token',
|
||||||
@@ -164,26 +140,6 @@ it('requires non-empty release gate checks before auto-sync can proceed', functi
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('extracts API ping commit metadata for release gate verification', function (): void {
|
|
||||||
$manager = new release_manager();
|
|
||||||
$method = new ReflectionMethod(release_manager::class, 'releaseGateApiPayloadCommitSha');
|
|
||||||
$method->setAccessible(true);
|
|
||||||
|
|
||||||
expect($method->invoke($manager, [
|
|
||||||
'success' => true,
|
|
||||||
'data' => [
|
|
||||||
'message' => 'pong',
|
|
||||||
'api_commit_sha' => '327a77edf48069c14cb592f298924b0ea1aaf208',
|
|
||||||
],
|
|
||||||
]))->toBe('327a77edf48069c14cb592f298924b0ea1aaf208')
|
|
||||||
->and($method->invoke($manager, [
|
|
||||||
'success' => true,
|
|
||||||
'data' => [
|
|
||||||
'backend_version' => '75c19bcce44fe3f0657d84b45dbc1c89f29332b4',
|
|
||||||
],
|
|
||||||
]))->toBe('75c19bcce44fe3f0657d84b45dbc1c89f29332b4');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes GitHub repository identifiers for private repository access checks', function (): void {
|
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('truckwash/backend-php'))->toBe('truckwash/backend-php');
|
||||||
expect(release_manager::normalizeGithubRepositoryName('https://github.com/truckwash/front-end-vue.git'))->toBe('truckwash/front-end-vue');
|
expect(release_manager::normalizeGithubRepositoryName('https://github.com/truckwash/front-end-vue.git'))->toBe('truckwash/front-end-vue');
|
||||||
@@ -632,7 +588,7 @@ it('resolves backend commit sha from API runtime environment in priority order',
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forces selected API commit into generated Coolify runtime env keys', function (): void {
|
it('injects selected API commit into Coolify runtime env unless explicitly set', function (): void {
|
||||||
$manager = new release_manager();
|
$manager = new release_manager();
|
||||||
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
|
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
|
||||||
$runtimeEnv->setAccessible(true);
|
$runtimeEnv->setAccessible(true);
|
||||||
@@ -645,52 +601,12 @@ it('forces selected API commit into generated Coolify runtime env keys', functio
|
|||||||
'commit_sha' => $selectedCommit,
|
'commit_sha' => $selectedCommit,
|
||||||
], [
|
], [
|
||||||
'coolify_env' => [
|
'coolify_env' => [
|
||||||
'API_COMMIT_SHA' => $explicitCommit,
|
|
||||||
'COMMIT_SHA' => $explicitCommit,
|
'COMMIT_SHA' => $explicitCommit,
|
||||||
'GITHUB_SHA' => $explicitCommit,
|
|
||||||
'RELEASE_COMMIT_SHA' => $explicitCommit,
|
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect($env['API_COMMIT_SHA'])->toBe($selectedCommit);
|
expect($env['API_COMMIT_SHA'])->toBe($selectedCommit);
|
||||||
expect($env['COMMIT_SHA'])->toBe($selectedCommit);
|
expect($env['COMMIT_SHA'])->toBe($explicitCommit);
|
||||||
expect($env['GITHUB_SHA'])->toBe($selectedCommit);
|
|
||||||
expect($env['RELEASE_COMMIT_SHA'])->toBe($selectedCommit);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the selected deployment commit before stale Coolify context commits', function (): void {
|
|
||||||
$manager = new release_manager();
|
|
||||||
$gitCommitSha = new ReflectionMethod(release_manager::class, 'releaseCoolifyGitCommitSha');
|
|
||||||
$gitCommitSha->setAccessible(true);
|
|
||||||
|
|
||||||
$selectedCommit = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
|
||||||
$staleCommit = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
|
|
||||||
|
|
||||||
expect($gitCommitSha->invoke($manager, [
|
|
||||||
'commit_sha' => $selectedCommit,
|
|
||||||
], [
|
|
||||||
'coolify_git_commit_sha' => $staleCommit,
|
|
||||||
'git_commit_sha' => $staleCommit,
|
|
||||||
]))->toBe($selectedCommit);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('injects selected frontend commit into Coolify runtime env for manifest builds', function (): void {
|
|
||||||
$manager = new release_manager();
|
|
||||||
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
|
|
||||||
$runtimeEnv->setAccessible(true);
|
|
||||||
|
|
||||||
$selectedCommit = '3333333333333333333333333333333333333333';
|
|
||||||
|
|
||||||
$env = $runtimeEnv->invoke($manager, [
|
|
||||||
'app' => 'frontend',
|
|
||||||
'commit_sha' => $selectedCommit,
|
|
||||||
], []);
|
|
||||||
|
|
||||||
expect($env['SOURCE_COMMIT'])->toBe($selectedCommit);
|
|
||||||
expect($env['RELEASE_COMMIT_SHA'])->toBe($selectedCommit);
|
|
||||||
expect($env['COMMIT_SHA'])->toBe($selectedCommit);
|
|
||||||
expect($env['GITHUB_SHA'])->toBe($selectedCommit);
|
|
||||||
expect($env['VITE_COMMIT_HASH'])->toBe($selectedCommit);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps beta API runtime environment on production database target', function (): void {
|
it('keeps beta API runtime environment on production database target', function (): void {
|
||||||
@@ -1055,7 +971,6 @@ it('defines release manager schema, routes, permissions, and system-status integ
|
|||||||
expect($manager)->toContain('createService');
|
expect($manager)->toContain('createService');
|
||||||
expect($manager)->toContain('updateService');
|
expect($manager)->toContain('updateService');
|
||||||
expect(file_get_contents(app_path('classes/coolify_api_client.php')))->toContain('updateApplicationEnvsBulk');
|
expect(file_get_contents(app_path('classes/coolify_api_client.php')))->toContain('updateApplicationEnvsBulk');
|
||||||
expect(file_get_contents(app_path('classes/coolify_api_client.php')))->toContain('stopApplication');
|
|
||||||
expect($manager)->toContain('channel_presets');
|
expect($manager)->toContain('channel_presets');
|
||||||
expect($manager)->toContain('target_presets');
|
expect($manager)->toContain('target_presets');
|
||||||
|
|
||||||
@@ -1446,57 +1361,6 @@ it('resolves release deployment endpoints from manual overrides, URLs, health ch
|
|||||||
]))->toBe('https://gateway.example.test/beta/frontend');
|
]))->toBe('https://gateway.example.test/beta/frontend');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('collects previous Coolify application UUIDs for stale route cleanup', function (): void {
|
|
||||||
$manager = new release_manager();
|
|
||||||
$previousApplications = new ReflectionMethod(release_manager::class, 'releaseCoolifyPreviousApplicationUuids');
|
|
||||||
$previousApplications->setAccessible(true);
|
|
||||||
|
|
||||||
expect($previousApplications->invoke($manager, [
|
|
||||||
'coolify_previous_artifact_app_uuid' => 'old-artifact-app',
|
|
||||||
'coolify_previous_application_uuid' => 'old-application',
|
|
||||||
'coolify_previous_application_uuids' => [
|
|
||||||
'old-application',
|
|
||||||
'active-application',
|
|
||||||
'other-old-application',
|
|
||||||
'',
|
|
||||||
],
|
|
||||||
], 'active-application'))->toBe([
|
|
||||||
'old-application',
|
|
||||||
'old-artifact-app',
|
|
||||||
'other-old-application',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes only generated API commit env rows before Coolify application env updates', function (): void {
|
|
||||||
$manager = new release_manager();
|
|
||||||
$deleteCommitEnvs = new ReflectionMethod(release_manager::class, 'deleteCoolifyGeneratedCommitEnvs');
|
|
||||||
$deleteCommitEnvs->setAccessible(true);
|
|
||||||
$client = new ReleaseManagerCoolifyEnvFake([
|
|
||||||
['uuid' => 'api-commit', 'key' => 'API_COMMIT_SHA'],
|
|
||||||
['uuid' => 'commit', 'key' => 'COMMIT_SHA'],
|
|
||||||
['uuid' => 'github', 'key' => 'GITHUB_SHA'],
|
|
||||||
['uuid' => 'release', 'key' => 'RELEASE_COMMIT_SHA'],
|
|
||||||
['uuid' => 'frontend-source', 'key' => 'SOURCE_COMMIT'],
|
|
||||||
['uuid' => 'secret', 'key' => 'CONFIG_DB_PASSWORD'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$deleteCommitEnvs->invoke($manager, $client, 'application-uuid', ['app' => 'api'], []);
|
|
||||||
|
|
||||||
expect($client->deleted)->toBe([
|
|
||||||
['application-uuid', 'api-commit'],
|
|
||||||
['application-uuid', 'commit'],
|
|
||||||
['application-uuid', 'github'],
|
|
||||||
['application-uuid', 'release'],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('waits for an in-flight automatic sync instead of passing the retry immediately', function (): void {
|
|
||||||
$manager = file_get_contents(app_path('classes/release_manager.php'));
|
|
||||||
|
|
||||||
expect($manager)->toContain('return $this->waitForReleaseAutoSyncEventResult($eventId, $channelId, $app, $commitSha, $gateInput);')
|
|
||||||
->and($manager)->toContain('Automatic container update is already being processed for event %d but did not finish');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redacts GitHub access metadata from public release versions', function (): void {
|
it('redacts GitHub access metadata from public release versions', function (): void {
|
||||||
$manager = new release_manager();
|
$manager = new release_manager();
|
||||||
$publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion');
|
$publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion');
|
||||||
|
|||||||
@@ -1,449 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use classes\licenseplaterecognizer;
|
|
||||||
|
|
||||||
class scanner_test_license_plate_recognizer extends licenseplaterecognizer
|
|
||||||
{
|
|
||||||
public int $config_reads = 0;
|
|
||||||
|
|
||||||
public function __construct(private readonly array $config_values)
|
|
||||||
{
|
|
||||||
parent::__construct(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function exposedRuntimeConfig(): array
|
|
||||||
{
|
|
||||||
return $this->runtimeConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function readRuntimeModuleConfig(): array
|
|
||||||
{
|
|
||||||
$this->config_reads++;
|
|
||||||
|
|
||||||
return $this->config_values;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class scanner_test_shared_cache_license_plate_recognizer extends licenseplaterecognizer
|
|
||||||
{
|
|
||||||
public static int $config_reads = 0;
|
|
||||||
public static array $config_values = [];
|
|
||||||
public static ?scanner_test_runtime_config_cache_store $cache_store = null;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
parent::__construct(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function exposedRuntimeConfig(): array
|
|
||||||
{
|
|
||||||
return $this->runtimeConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function shouldUseSharedRuntimeConfigCache(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function readRuntimeModuleConfig(): array
|
|
||||||
{
|
|
||||||
self::$config_reads++;
|
|
||||||
|
|
||||||
return self::$config_values;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function runtimeConfigCacheStore(): ?object
|
|
||||||
{
|
|
||||||
return self::$cache_store;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class scanner_test_runtime_config_cache_store
|
|
||||||
{
|
|
||||||
public array $store = [];
|
|
||||||
public array $set_ex_calls = [];
|
|
||||||
|
|
||||||
public function get(string $key): ?string
|
|
||||||
{
|
|
||||||
return $this->store[$key] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setEx(string $key, string $value, int $expiration): void
|
|
||||||
{
|
|
||||||
$this->store[$key] = $value;
|
|
||||||
$this->set_ex_calls[] = [
|
|
||||||
'key' => $key,
|
|
||||||
'value' => $value,
|
|
||||||
'expiration' => $expiration,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset_license_plate_recognizer_runtime_config_cache(): void
|
|
||||||
{
|
|
||||||
$cacheProperty = new ReflectionProperty(licenseplaterecognizer::class, 'runtime_config_cache');
|
|
||||||
$cacheProperty->setValue(null, null);
|
|
||||||
scanner_test_shared_cache_license_plate_recognizer::$config_reads = 0;
|
|
||||||
scanner_test_shared_cache_license_plate_recognizer::$config_values = [];
|
|
||||||
scanner_test_shared_cache_license_plate_recognizer::$cache_store = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
reset_license_plate_recognizer_runtime_config_cache();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
reset_license_plate_recognizer_runtime_config_cache();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends data URI images to Plate Recognizer as multipart bytes with fast mode enabled', function (): void {
|
|
||||||
$reflection = new ReflectionClass(licenseplaterecognizer::class);
|
|
||||||
$recognizer = $reflection->newInstanceWithoutConstructor();
|
|
||||||
$method = $reflection->getMethod('buildPlateReaderPayload');
|
|
||||||
|
|
||||||
$payload = $method->invoke($recognizer, 'data:image/jpeg;base64,' . base64_encode('jpeg-bytes'));
|
|
||||||
|
|
||||||
expect($payload['upload'])->toBeInstanceOf(CURLStringFile::class);
|
|
||||||
expect($payload['upload']->data)->toBe('jpeg-bytes');
|
|
||||||
expect($payload['upload']->postname)->toBe('license-plate.jpg');
|
|
||||||
expect($payload['upload']->mime)->toBe('image/jpeg');
|
|
||||||
expect(json_decode($payload['config'], true))->toBe([
|
|
||||||
'mode' => 'fast',
|
|
||||||
'plates_per_vehicle' => 1,
|
|
||||||
'zoom_in_vehicles' => 0,
|
|
||||||
]);
|
|
||||||
expect($payload['regions'])->toBe('dk,de,se,no');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses precomputed Plate Reader config strings on the scanner payload hot path', function (): void {
|
|
||||||
$source = file_get_contents(app_path('classes/licenseplaterecognizer.php'));
|
|
||||||
|
|
||||||
expect($source)->not->toBeFalse();
|
|
||||||
expect($source)->toContain('PLATE_READER_CONFIG_JSON');
|
|
||||||
expect($source)->toContain('RESULT_CACHE_CONTEXT');
|
|
||||||
expect($source)->toContain("'config' => self::PLATE_READER_CONFIG_JSON");
|
|
||||||
expect($source)->toContain('return self::RESULT_CACHE_CONTEXT;');
|
|
||||||
expect($source)->not->toContain("'config' => json_encode(self::PLATE_READER_CONFIG");
|
|
||||||
expect($source)->not->toContain("return json_encode([\n 'config' => self::PLATE_READER_CONFIG");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps already raw base64 upload data unchanged', function (): void {
|
|
||||||
$reflection = new ReflectionClass(licenseplaterecognizer::class);
|
|
||||||
$recognizer = $reflection->newInstanceWithoutConstructor();
|
|
||||||
$method = $reflection->getMethod('buildPlateReaderPayload');
|
|
||||||
|
|
||||||
$payload = $method->invoke($recognizer, 'abc123');
|
|
||||||
|
|
||||||
expect($payload['upload'])->toBe('abc123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not retain raw upstream Plate Recognizer responses in compact scanner results', function (): void {
|
|
||||||
$source = file_get_contents(app_path('classes/licenseplaterecognizer.php'));
|
|
||||||
|
|
||||||
expect($source)->not->toBeFalse();
|
|
||||||
expect($source)->not->toContain("'raw_response' => \$response_data");
|
|
||||||
expect($source)->toContain("'license_plate_number' => \$response_data['results'][0]['plate'] ?? null");
|
|
||||||
expect($source)->toContain("'confidence' => \$response_data['results'][0]['score'] ?? null");
|
|
||||||
expect($source)->toContain("'message' => 'No license plate detected.'");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends multipart route image bytes to Plate Recognizer without base64 wrapping', function (): void {
|
|
||||||
$reflection = new ReflectionClass(licenseplaterecognizer::class);
|
|
||||||
$recognizer = $reflection->newInstanceWithoutConstructor();
|
|
||||||
$uploadMethod = $reflection->getMethod('buildUploadValueFromBytes');
|
|
||||||
$payloadMethod = $reflection->getMethod('buildPlateReaderPayloadFromUpload');
|
|
||||||
|
|
||||||
$upload = $uploadMethod->invoke($recognizer, 'jpeg-bytes', 'image/jpeg');
|
|
||||||
$payload = $payloadMethod->invoke($recognizer, $upload);
|
|
||||||
|
|
||||||
expect($payload['upload'])->toBeInstanceOf(CURLStringFile::class);
|
|
||||||
expect($payload['upload']->data)->toBe('jpeg-bytes');
|
|
||||||
expect($payload['upload']->postname)->toBe('license-plate.jpg');
|
|
||||||
expect($payload['upload']->mime)->toBe('image/jpeg');
|
|
||||||
expect(json_decode($payload['config'], true))->toBe([
|
|
||||||
'mode' => 'fast',
|
|
||||||
'plates_per_vehicle' => 1,
|
|
||||||
'zoom_in_vehicles' => 0,
|
|
||||||
]);
|
|
||||||
expect($payload['regions'])->toBe('dk,de,se,no');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends multipart route upload files to Plate Recognizer without reading bytes into PHP memory', function (): void {
|
|
||||||
$path = tempnam(sys_get_temp_dir(), 'lpr-upload-');
|
|
||||||
expect($path)->not->toBeFalse();
|
|
||||||
file_put_contents($path, 'jpeg-bytes');
|
|
||||||
|
|
||||||
try {
|
|
||||||
$reflection = new ReflectionClass(licenseplaterecognizer::class);
|
|
||||||
$recognizer = $reflection->newInstanceWithoutConstructor();
|
|
||||||
$uploadMethod = $reflection->getMethod('buildUploadValueFromFile');
|
|
||||||
$payloadMethod = $reflection->getMethod('buildPlateReaderPayloadFromUpload');
|
|
||||||
|
|
||||||
$upload = $uploadMethod->invoke($recognizer, $path, 'image/jpeg');
|
|
||||||
$payload = $payloadMethod->invoke($recognizer, $upload);
|
|
||||||
|
|
||||||
expect($payload['upload'])->toBeInstanceOf(CURLFile::class);
|
|
||||||
expect($payload['upload']->getFilename())->toBe($path);
|
|
||||||
expect($payload['upload']->getPostFilename())->toBe('license-plate.jpg');
|
|
||||||
expect($payload['upload']->getMimeType())->toBe('image/jpeg');
|
|
||||||
expect(json_decode($payload['config'], true))->toBe([
|
|
||||||
'mode' => 'fast',
|
|
||||||
'plates_per_vehicle' => 1,
|
|
||||||
'zoom_in_vehicles' => 0,
|
|
||||||
]);
|
|
||||||
expect($payload['regions'])->toBe('dk,de,se,no');
|
|
||||||
} finally {
|
|
||||||
@unlink($path);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('records Plate Recognizer processing time from the upstream response', function (): void {
|
|
||||||
$reflection = new ReflectionClass(licenseplaterecognizer::class);
|
|
||||||
$recognizer = $reflection->newInstanceWithoutConstructor();
|
|
||||||
$method = $reflection->getMethod('recordResponseTimings');
|
|
||||||
|
|
||||||
$method->invoke($recognizer, [
|
|
||||||
'processing_time' => 58.184,
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect($recognizer->getLastTimings())->toHaveKey('upstream_processing', 58.184);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can skip config object setup and cache runtime config for scanner requests', function (): void {
|
|
||||||
$recognizer = new scanner_test_license_plate_recognizer([
|
|
||||||
'enabled' => 'true',
|
|
||||||
'api_key' => 'test-key',
|
|
||||||
]);
|
|
||||||
$configProperty = new ReflectionProperty(licenseplaterecognizer::class, 'config');
|
|
||||||
|
|
||||||
expect($configProperty->isInitialized($recognizer))->toBeFalse();
|
|
||||||
expect($recognizer->exposedRuntimeConfig())->toBe([
|
|
||||||
'enabled' => true,
|
|
||||||
'api_key' => 'test-key',
|
|
||||||
]);
|
|
||||||
expect($recognizer->exposedRuntimeConfig())->toBe([
|
|
||||||
'enabled' => true,
|
|
||||||
'api_key' => 'test-key',
|
|
||||||
]);
|
|
||||||
expect($recognizer->config_reads)->toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads runtime config once in the scanner recognition hot path', function (): void {
|
|
||||||
$source = file_get_contents(app_path('classes/licenseplaterecognizer.php'));
|
|
||||||
|
|
||||||
expect($source)->not->toBeFalse();
|
|
||||||
expect($source)->toContain('$runtime_config = $this->runtimeConfig();');
|
|
||||||
expect($source)->toContain('$api_key = $runtime_config[\'api_key\'];');
|
|
||||||
expect($source)->not->toContain('$this->requireModuleEnabled();' . "\n " . '$api_key = $this->runtimeConfig()[\'api_key\'];');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shares runtime config briefly across scanner recognizer instances', function (): void {
|
|
||||||
scanner_test_shared_cache_license_plate_recognizer::$config_values = [
|
|
||||||
'enabled' => 'true',
|
|
||||||
'api_key' => 'cached-key',
|
|
||||||
];
|
|
||||||
|
|
||||||
$first = new scanner_test_shared_cache_license_plate_recognizer();
|
|
||||||
expect($first->exposedRuntimeConfig())->toBe([
|
|
||||||
'enabled' => true,
|
|
||||||
'api_key' => 'cached-key',
|
|
||||||
]);
|
|
||||||
|
|
||||||
scanner_test_shared_cache_license_plate_recognizer::$config_values = [
|
|
||||||
'enabled' => 'false',
|
|
||||||
'api_key' => 'new-key',
|
|
||||||
];
|
|
||||||
|
|
||||||
$second = new scanner_test_shared_cache_license_plate_recognizer();
|
|
||||||
expect($second->exposedRuntimeConfig())->toBe([
|
|
||||||
'enabled' => true,
|
|
||||||
'api_key' => 'cached-key',
|
|
||||||
]);
|
|
||||||
expect(scanner_test_shared_cache_license_plate_recognizer::$config_reads)->toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refreshes the shared runtime config cache after the short scanner ttl', function (): void {
|
|
||||||
$cacheProperty = new ReflectionProperty(licenseplaterecognizer::class, 'runtime_config_cache');
|
|
||||||
$cacheProperty->setValue(null, [
|
|
||||||
'values' => [
|
|
||||||
'enabled' => true,
|
|
||||||
'api_key' => 'stale-key',
|
|
||||||
],
|
|
||||||
'cached_at' => microtime(true) - 20,
|
|
||||||
]);
|
|
||||||
scanner_test_shared_cache_license_plate_recognizer::$config_values = [
|
|
||||||
'enabled' => 'false',
|
|
||||||
'api_key' => 'fresh-key',
|
|
||||||
];
|
|
||||||
|
|
||||||
$recognizer = new scanner_test_shared_cache_license_plate_recognizer();
|
|
||||||
|
|
||||||
expect($recognizer->exposedRuntimeConfig())->toBe([
|
|
||||||
'enabled' => false,
|
|
||||||
'api_key' => 'fresh-key',
|
|
||||||
]);
|
|
||||||
expect(scanner_test_shared_cache_license_plate_recognizer::$config_reads)->toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses Redis-backed runtime config cache across scanner requests when available', function (): void {
|
|
||||||
$cache = new scanner_test_runtime_config_cache_store();
|
|
||||||
scanner_test_shared_cache_license_plate_recognizer::$cache_store = $cache;
|
|
||||||
scanner_test_shared_cache_license_plate_recognizer::$config_values = [
|
|
||||||
'enabled' => 'true',
|
|
||||||
'api_key' => 'redis-key',
|
|
||||||
];
|
|
||||||
|
|
||||||
$first = new scanner_test_shared_cache_license_plate_recognizer();
|
|
||||||
expect($first->exposedRuntimeConfig())->toBe([
|
|
||||||
'enabled' => true,
|
|
||||||
'api_key' => 'redis-key',
|
|
||||||
]);
|
|
||||||
expect(scanner_test_shared_cache_license_plate_recognizer::$config_reads)->toBe(1);
|
|
||||||
expect($cache->set_ex_calls)->toHaveCount(1);
|
|
||||||
expect($cache->set_ex_calls[0]['expiration'])->toBe(15);
|
|
||||||
|
|
||||||
$cacheProperty = new ReflectionProperty(licenseplaterecognizer::class, 'runtime_config_cache');
|
|
||||||
$cacheProperty->setValue(null, null);
|
|
||||||
scanner_test_shared_cache_license_plate_recognizer::$config_values = [
|
|
||||||
'enabled' => 'false',
|
|
||||||
'api_key' => 'db-should-not-be-read',
|
|
||||||
];
|
|
||||||
|
|
||||||
$second = new scanner_test_shared_cache_license_plate_recognizer();
|
|
||||||
|
|
||||||
expect($second->exposedRuntimeConfig())->toBe([
|
|
||||||
'enabled' => true,
|
|
||||||
'api_key' => 'redis-key',
|
|
||||||
]);
|
|
||||||
expect(scanner_test_shared_cache_license_plate_recognizer::$config_reads)->toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats false runtime config values as disabled', function (): void {
|
|
||||||
$recognizer = new scanner_test_license_plate_recognizer([
|
|
||||||
'enabled' => 'false',
|
|
||||||
'api_key' => 'test-key',
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect($recognizer->exposedRuntimeConfig())->toBe([
|
|
||||||
'enabled' => false,
|
|
||||||
'api_key' => 'test-key',
|
|
||||||
]);
|
|
||||||
expect(fn () => $recognizer->requireModuleEnabled())
|
|
||||||
->toThrow(Exception::class, 'licenseplaterecognizer module is not enabled.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can point Plate Recognizer calls at a local measurement upstream', function (): void {
|
|
||||||
$recognizer = new licenseplaterecognizer(false, 'http://127.0.0.1:18081/');
|
|
||||||
$apiUrlProperty = new ReflectionProperty(licenseplaterecognizer::class, 'api_url');
|
|
||||||
|
|
||||||
expect($apiUrlProperty->getValue($recognizer))->toBe('http://127.0.0.1:18081');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables curl expect continue waits for large multipart uploads', function (): void {
|
|
||||||
$source = file_get_contents(app_path('classes/licenseplaterecognizer.php'));
|
|
||||||
|
|
||||||
expect($source)->not->toBeFalse();
|
|
||||||
expect($source)->toContain("'Expect:'");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables tcp write coalescing on scanner Plate Recognizer requests when curl supports it', function (): void {
|
|
||||||
$source = file_get_contents(app_path('classes/licenseplaterecognizer.php'));
|
|
||||||
|
|
||||||
expect($source)->not->toBeFalse();
|
|
||||||
expect($source)->toContain('$curl_options = [');
|
|
||||||
expect($source)->toContain("defined('CURLOPT_TCP_NODELAY')");
|
|
||||||
expect($source)->toContain("\$curl_options[(int)constant('CURLOPT_TCP_NODELAY')] = true;");
|
|
||||||
expect($source)->toContain('curl_setopt_array($ch, $curl_options);');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not capture outgoing curl headers on the scanner hot path', function (): void {
|
|
||||||
$source = file_get_contents(app_path('classes/licenseplaterecognizer.php'));
|
|
||||||
|
|
||||||
expect($source)->not->toBeFalse();
|
|
||||||
expect($source)->not->toContain('CURLINFO_HEADER_OUT');
|
|
||||||
expect($source)->toContain('curl_setopt_array($ch');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('records only selected curl timing fields on the scanner hot path', function (): void {
|
|
||||||
$source = file_get_contents(app_path('classes/licenseplaterecognizer.php'));
|
|
||||||
|
|
||||||
expect($source)->not->toBeFalse();
|
|
||||||
expect($source)->toContain('$this->recordCurlTimings($ch);');
|
|
||||||
expect($source)->not->toContain('$curl_info = curl_getinfo($ch);');
|
|
||||||
expect($source)->toContain('CURLINFO_NAMELOOKUP_TIME');
|
|
||||||
expect($source)->toContain('CURLINFO_TOTAL_TIME');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('aborts upstream recognition transfers when the HTTP client disconnects', function (): void {
|
|
||||||
$source = file_get_contents(app_path('classes/licenseplaterecognizer.php'));
|
|
||||||
|
|
||||||
expect($source)->not->toBeFalse();
|
|
||||||
expect($source)->toContain('CURLOPT_NOPROGRESS');
|
|
||||||
expect($source)->toContain('CURLOPT_XFERINFOFUNCTION');
|
|
||||||
expect($source)->toContain('connection_aborted() ? 1 : 0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bounds Plate Recognizer scanner uploads with conservative curl timeouts', function (): void {
|
|
||||||
$source = file_get_contents(app_path('classes/licenseplaterecognizer.php'));
|
|
||||||
|
|
||||||
expect($source)->not->toBeFalse();
|
|
||||||
expect($source)->toContain('PLATE_READER_CONNECT_TIMEOUT_MS = 1000');
|
|
||||||
expect($source)->toContain('PLATE_READER_TOTAL_TIMEOUT_MS = 4500');
|
|
||||||
expect($source)->toContain('CURLOPT_CONNECTTIMEOUT_MS => self::PLATE_READER_CONNECT_TIMEOUT_MS');
|
|
||||||
expect($source)->toContain('CURLOPT_TIMEOUT_MS => self::PLATE_READER_TOTAL_TIMEOUT_MS');
|
|
||||||
expect($source)->toContain('CURLOPT_NOSIGNAL => true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses a short exact-image result cache before calling Plate Recognizer', function (): void {
|
|
||||||
$source = file_get_contents(app_path('classes/licenseplaterecognizer.php'));
|
|
||||||
|
|
||||||
expect($source)->not->toBeFalse();
|
|
||||||
expect($source)->toContain('RESULT_CACHE_TTL_SECONDS = 10');
|
|
||||||
expect($source)->toContain('RESULT_CACHE_REDIS_KEY_PREFIX');
|
|
||||||
expect($source)->toContain('$cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key);');
|
|
||||||
expect($source)->toContain('return $cached_result;');
|
|
||||||
expect($source)->toContain('$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);');
|
|
||||||
expect($source)->toContain('$this->last_timings[\'cache\']');
|
|
||||||
expect($source)->toContain('$this->last_timings[\'cache_hit\'] = 1;');
|
|
||||||
expect($source)->toContain('$this->last_timings[\'cache_miss\'] = 1;');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not hash multipart upload temp files for result-cache misses', function (): void {
|
|
||||||
$source = file_get_contents(app_path('classes/licenseplaterecognizer.php'));
|
|
||||||
|
|
||||||
expect($source)->not->toBeFalse();
|
|
||||||
expect($source)->toContain('public function licenseplaterecognizerUploadFile');
|
|
||||||
expect($source)->toContain('return $this->recognizePlate(');
|
|
||||||
expect($source)->toContain('$this->buildUploadValueFromFile($image_path, $mime_type)');
|
|
||||||
expect($source)->not->toContain('buildResultCacheKeyFromFile');
|
|
||||||
expect($source)->not->toContain('hash_update_file($context, $image_path)');
|
|
||||||
expect($source)->not->toContain('file_get_contents($image_path');
|
|
||||||
expect($source)->not->toContain('hash_update($context, $chunk)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not hash raw scanner body bytes on the live camera upload path', function (): void {
|
|
||||||
$source = file_get_contents(app_path('classes/licenseplaterecognizer.php'));
|
|
||||||
$route = file_get_contents(app_path('routes/moduleScannerRoute.php'));
|
|
||||||
|
|
||||||
expect($source)->not->toBeFalse();
|
|
||||||
expect($route)->not->toBeFalse();
|
|
||||||
expect($source)->toContain('public function licenseplaterecognizerUploadUncached');
|
|
||||||
expect($source)->toContain('fn () => $this->buildPlateReaderPayloadFromUpload(');
|
|
||||||
expect($route)->toContain('licenseplaterecognizerUploadUncached($raw_image_upload');
|
|
||||||
expect($route)->not->toContain('licenseplaterecognizerUpload($raw_image_upload');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds stable result cache keys from equivalent in-memory image bytes', function (): void {
|
|
||||||
$reflection = new ReflectionClass(licenseplaterecognizer::class);
|
|
||||||
$recognizer = $reflection->newInstanceWithoutConstructor();
|
|
||||||
$bytesMethod = $reflection->getMethod('buildResultCacheKeyFromBytes');
|
|
||||||
$uploadStringMethod = $reflection->getMethod('buildResultCacheKeyFromUploadString');
|
|
||||||
|
|
||||||
$bytesKey = $bytesMethod->invoke($recognizer, 'jpeg-bytes');
|
|
||||||
$dataUriKey = $uploadStringMethod->invoke($recognizer, 'data:image/jpeg;base64,' . base64_encode('jpeg-bytes'));
|
|
||||||
$differentKey = $bytesMethod->invoke($recognizer, 'other-jpeg-bytes');
|
|
||||||
|
|
||||||
expect($dataUriKey)->toBe($bytesKey);
|
|
||||||
expect($differentKey)->not->toBe($bytesKey);
|
|
||||||
expect($bytesKey)->toStartWith('licenseplaterecognizer:result:v1:');
|
|
||||||
});
|
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use routes\moduleScannerRoute;
|
|
||||||
|
|
||||||
app_require('routes/moduleScannerRoute.php');
|
|
||||||
|
|
||||||
it('returns no-plate LPR results without a failed HTTP status', function (): void {
|
it('returns no-plate LPR results without a failed HTTP status', function (): void {
|
||||||
$route = file_get_contents(app_path('routes/moduleScannerRoute.php'));
|
$route = file_get_contents(app_path('routes/moduleScannerRoute.php'));
|
||||||
|
|
||||||
@@ -12,124 +8,3 @@ it('returns no-plate LPR results without a failed HTTP status', function (): voi
|
|||||||
expect($route)->toContain('], 200);');
|
expect($route)->toContain('], 200);');
|
||||||
expect($route)->not->toContain("throw new Exception('License plate extraction failed.')");
|
expect($route)->not->toContain("throw new Exception('License plate extraction failed.')");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns low-confidence LPR results without a failed HTTP status', function (): void {
|
|
||||||
$route = file_get_contents(app_path('routes/moduleScannerRoute.php'));
|
|
||||||
|
|
||||||
expect($route)->not->toBeFalse();
|
|
||||||
expect($route)->toContain("'reason' => 'low_confidence_license_plate'");
|
|
||||||
expect($route)->toContain("'message' => 'License plate recognition confidence too low.'");
|
|
||||||
expect($route)->toContain("'confidence' => \$lpr_result['confidence']");
|
|
||||||
expect($route)->not->toContain('License plate recognition confidence too low. Score:');
|
|
||||||
expect($route)->not->toContain('use Exception;');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not store scanner images before LPR recognition', function (): void {
|
|
||||||
$route = file_get_contents(app_path('routes/moduleScannerRoute.php'));
|
|
||||||
|
|
||||||
expect($route)->not->toBeFalse();
|
|
||||||
expect($route)->toContain('new licenseplaterecognizer(false)');
|
|
||||||
expect($route)->toContain('getLPRImageUpload()');
|
|
||||||
expect($route)->toContain('getLPRRawImageUpload()');
|
|
||||||
expect($route)->toContain('$base64_image = null;');
|
|
||||||
expect($route)->toContain('if ($image_upload === null && $raw_image_upload === null)');
|
|
||||||
expect($route)->toContain('licenseplaterecognizerUploadFile($image_upload');
|
|
||||||
expect($route)->toContain('licenseplaterecognizerUploadUncached($raw_image_upload');
|
|
||||||
expect($route)->toContain("'path' => \$tmp_name");
|
|
||||||
expect($route)->toContain("'data' => \$image_data");
|
|
||||||
expect($route)->toContain("file_get_contents('php://input')");
|
|
||||||
expect($route)->toContain("str_starts_with(\$mime_type, 'image/')");
|
|
||||||
expect($route)->toContain('$recognizer->licenseplaterecognizer((string)$base64_image)');
|
|
||||||
expect($route)->toContain('$route_started_at = microtime(true);');
|
|
||||||
expect($route)->toContain('$client_timings = self::getLPRClientTimings();');
|
|
||||||
expect($route)->toContain('array_merge($client_timings, $recognizer->getLastTimings())');
|
|
||||||
expect($route)->toContain("header('Server-Timing: '");
|
|
||||||
expect($route)->toContain("'client_capture'");
|
|
||||||
expect($route)->toContain("'client_preflight'");
|
|
||||||
expect($route)->toContain("'client_visual_fingerprint'");
|
|
||||||
expect($route)->toContain("'client_draw'");
|
|
||||||
expect($route)->toContain("'client_encode'");
|
|
||||||
expect($route)->toContain("'client_frame_width'");
|
|
||||||
expect($route)->toContain("'client_frame_height'");
|
|
||||||
expect($route)->toContain("'client_frame_bytes'");
|
|
||||||
expect($route)->toContain("'cache'");
|
|
||||||
expect($route)->toContain("'cache_hit'");
|
|
||||||
expect($route)->toContain("'cache_miss'");
|
|
||||||
expect($route)->toContain("\$timings['local'] = self::getLPRLocalDuration(\$timings);");
|
|
||||||
expect($route)->toContain("'local'");
|
|
||||||
expect($route)->toContain("'upstream_dns'");
|
|
||||||
expect($route)->toContain("'upstream_connect'");
|
|
||||||
expect($route)->toContain("'upstream_tls'");
|
|
||||||
expect($route)->toContain("'upstream_pretransfer'");
|
|
||||||
expect($route)->toContain("'upstream_ttfb'");
|
|
||||||
expect($route)->toContain("'upstream_total'");
|
|
||||||
expect($route)->toContain("'upstream_processing'");
|
|
||||||
expect($route)->toContain("'route_total'");
|
|
||||||
expect($route)->toContain("'request_total'");
|
|
||||||
expect($route)->toContain('HTTP_X_LPR_CLIENT_CAPTURE_MS');
|
|
||||||
expect($route)->toContain('HTTP_X_LPR_CLIENT_FRAME_BYTES');
|
|
||||||
expect($route)->toContain('$value = $_GET[$field] ?? null;');
|
|
||||||
expect($route)->toContain("isset(\$_SERVER['REQUEST_TIME_FLOAT'])");
|
|
||||||
expect($route)->not->toContain("requireParameters(['base64_image'])");
|
|
||||||
expect($route)->not->toContain('file_get_contents($tmp_name)');
|
|
||||||
expect($route)->not->toContain('storeTempImageFromBase64');
|
|
||||||
expect($route)->not->toContain('new upload_store');
|
|
||||||
expect($route)->not->toContain('new openai');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads raw scanner client timing metadata from request query parameters', function (): void {
|
|
||||||
$previousGet = $_GET;
|
|
||||||
$previousPost = $_POST;
|
|
||||||
$headerNames = [
|
|
||||||
'HTTP_X_LPR_CLIENT_CAPTURE_MS',
|
|
||||||
'HTTP_X_LPR_CLIENT_PREFLIGHT_MS',
|
|
||||||
'HTTP_X_LPR_CLIENT_DRAW_MS',
|
|
||||||
'HTTP_X_LPR_CLIENT_ENCODE_MS',
|
|
||||||
'HTTP_X_LPR_CLIENT_VISUAL_FINGERPRINT_MS',
|
|
||||||
'HTTP_X_LPR_CLIENT_FRAME_WIDTH',
|
|
||||||
'HTTP_X_LPR_CLIENT_FRAME_HEIGHT',
|
|
||||||
'HTTP_X_LPR_CLIENT_FRAME_BYTES',
|
|
||||||
];
|
|
||||||
$previousHeaders = [];
|
|
||||||
foreach ($headerNames as $headerName) {
|
|
||||||
$previousHeaders[$headerName] = $_SERVER[$headerName] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$_GET = [
|
|
||||||
'client_capture_ms' => '12.345',
|
|
||||||
'client_preflight_ms' => '1.500',
|
|
||||||
'client_draw_ms' => '2.250',
|
|
||||||
'client_encode_ms' => '8.500',
|
|
||||||
'client_visual_fingerprint_ms' => '0.750',
|
|
||||||
'client_frame_width' => '384',
|
|
||||||
'client_frame_height' => '216',
|
|
||||||
'client_frame_bytes' => '12345',
|
|
||||||
];
|
|
||||||
$_POST = [];
|
|
||||||
|
|
||||||
$reflection = new ReflectionClass(moduleScannerRoute::class);
|
|
||||||
$method = $reflection->getMethod('getLPRClientTimings');
|
|
||||||
|
|
||||||
expect($method->invoke(null))->toMatchArray([
|
|
||||||
'client_capture' => 12.345,
|
|
||||||
'client_preflight' => 1.5,
|
|
||||||
'client_draw' => 2.25,
|
|
||||||
'client_encode' => 8.5,
|
|
||||||
'client_visual_fingerprint' => 0.75,
|
|
||||||
'client_frame_width' => 384.0,
|
|
||||||
'client_frame_height' => 216.0,
|
|
||||||
'client_frame_bytes' => 12345.0,
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
$_GET = $previousGet;
|
|
||||||
$_POST = $previousPost;
|
|
||||||
foreach ($previousHeaders as $headerName => $previousValue) {
|
|
||||||
if ($previousValue === null) {
|
|
||||||
unset($_SERVER[$headerName]);
|
|
||||||
} else {
|
|
||||||
$_SERVER[$headerName] = $previousValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
app_require('traits/module_config_variable_t.php');
|
|
||||||
|
|
||||||
use traits\module_config_variable;
|
|
||||||
|
|
||||||
function selfserve_module_config_database_config(): array
|
|
||||||
{
|
|
||||||
$subject = new class {
|
|
||||||
use module_config_variable;
|
|
||||||
};
|
|
||||||
|
|
||||||
$method = new ReflectionMethod($subject::class, 'readDatabaseConfigFromEnvironment');
|
|
||||||
$method->setAccessible(true);
|
|
||||||
|
|
||||||
return $method->invoke(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selfserve_module_config_with_env(array $values, callable $callback): mixed
|
|
||||||
{
|
|
||||||
$keys = [
|
|
||||||
'CONFIG_DB_TARGET',
|
|
||||||
'CONFIG_DB_HOST',
|
|
||||||
'CONFIG_DB_USER',
|
|
||||||
'CONFIG_DB_PASSWORD',
|
|
||||||
'CONFIG_DB_DATABASE',
|
|
||||||
'CONFIG_DB_PORT',
|
|
||||||
'CONFIG_DB_SSL_MODE',
|
|
||||||
'CONFIG_DB_DEBUG_HOST',
|
|
||||||
'CONFIG_DB_DEBUG_USER',
|
|
||||||
'CONFIG_DB_DEBUG_PASSWORD',
|
|
||||||
'CONFIG_DB_DEBUG_DATABASE',
|
|
||||||
'CONFIG_DB_DEBUG_PORT',
|
|
||||||
'CONFIG_DB_DEBUG_SSL_MODE',
|
|
||||||
];
|
|
||||||
$previous = [];
|
|
||||||
|
|
||||||
foreach ($keys as $key) {
|
|
||||||
$previous[$key] = [
|
|
||||||
'env' => getenv($key),
|
|
||||||
'has_env' => array_key_exists($key, $_ENV),
|
|
||||||
'super_env' => $_ENV[$key] ?? null,
|
|
||||||
'has_server' => array_key_exists($key, $_SERVER),
|
|
||||||
'server' => $_SERVER[$key] ?? null,
|
|
||||||
];
|
|
||||||
putenv($key);
|
|
||||||
unset($_ENV[$key], $_SERVER[$key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
foreach ($values as $key => $value) {
|
|
||||||
putenv($key . '=' . (string)$value);
|
|
||||||
$_ENV[$key] = (string)$value;
|
|
||||||
$_SERVER[$key] = (string)$value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $callback();
|
|
||||||
} finally {
|
|
||||||
foreach ($previous as $key => $state) {
|
|
||||||
$state['env'] === false
|
|
||||||
? putenv($key)
|
|
||||||
: putenv($key . '=' . $state['env']);
|
|
||||||
|
|
||||||
if ($state['has_env']) {
|
|
||||||
$_ENV[$key] = $state['super_env'];
|
|
||||||
} else {
|
|
||||||
unset($_ENV[$key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($state['has_server']) {
|
|
||||||
$_SERVER[$key] = $state['server'];
|
|
||||||
} else {
|
|
||||||
unset($_SERVER[$key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('reads debug database configuration when the debug target is selected', function (): void {
|
|
||||||
$config = selfserve_module_config_with_env([
|
|
||||||
'CONFIG_DB_TARGET' => 'debug',
|
|
||||||
'CONFIG_DB_HOST' => '',
|
|
||||||
'CONFIG_DB_USER' => '',
|
|
||||||
'CONFIG_DB_PASSWORD' => '',
|
|
||||||
'CONFIG_DB_DATABASE' => '',
|
|
||||||
'CONFIG_DB_PORT' => '',
|
|
||||||
'CONFIG_DB_SSL_MODE' => '',
|
|
||||||
'CONFIG_DB_DEBUG_HOST' => 'mysql-debug',
|
|
||||||
'CONFIG_DB_DEBUG_USER' => 'root',
|
|
||||||
'CONFIG_DB_DEBUG_PASSWORD' => 'debug_root_password',
|
|
||||||
'CONFIG_DB_DEBUG_DATABASE' => 'nnks_db_debug',
|
|
||||||
'CONFIG_DB_DEBUG_PORT' => '3306',
|
|
||||||
'CONFIG_DB_DEBUG_SSL_MODE' => 'DISABLED',
|
|
||||||
], static fn(): array => selfserve_module_config_database_config());
|
|
||||||
|
|
||||||
expect($config)->toMatchArray([
|
|
||||||
'host' => 'mysql-debug',
|
|
||||||
'user' => 'root',
|
|
||||||
'password' => 'debug_root_password',
|
|
||||||
'database' => 'nnks_db_debug',
|
|
||||||
'port' => 3306,
|
|
||||||
'ssl_mode' => 'DISABLED',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps live database configuration as the default target', function (): void {
|
|
||||||
$config = selfserve_module_config_with_env([
|
|
||||||
'CONFIG_DB_HOST' => 'live-db',
|
|
||||||
'CONFIG_DB_USER' => 'live-user',
|
|
||||||
'CONFIG_DB_PASSWORD' => 'live-password',
|
|
||||||
'CONFIG_DB_DATABASE' => 'live-database',
|
|
||||||
'CONFIG_DB_PORT' => '3307',
|
|
||||||
'CONFIG_DB_SSL_MODE' => 'REQUIRED',
|
|
||||||
'CONFIG_DB_DEBUG_HOST' => 'mysql-debug',
|
|
||||||
'CONFIG_DB_DEBUG_USER' => 'root',
|
|
||||||
'CONFIG_DB_DEBUG_PASSWORD' => 'debug_root_password',
|
|
||||||
'CONFIG_DB_DEBUG_DATABASE' => 'nnks_db_debug',
|
|
||||||
'CONFIG_DB_DEBUG_PORT' => '3306',
|
|
||||||
'CONFIG_DB_DEBUG_SSL_MODE' => 'DISABLED',
|
|
||||||
], static fn(): array => selfserve_module_config_database_config());
|
|
||||||
|
|
||||||
expect($config)->toMatchArray([
|
|
||||||
'host' => 'live-db',
|
|
||||||
'user' => 'live-user',
|
|
||||||
'password' => 'live-password',
|
|
||||||
'database' => 'live-database',
|
|
||||||
'port' => 3307,
|
|
||||||
'ssl_mode' => 'REQUIRED',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -43,17 +43,16 @@ function selfserve_eligibility_method_block(string $route, string $signature): s
|
|||||||
it('authorizes customer eligibility preview lanes before returning task attachments', function (): void {
|
it('authorizes customer eligibility preview lanes before returning task attachments', function (): void {
|
||||||
$route = selfserve_eligibility_route_source();
|
$route = selfserve_eligibility_route_source();
|
||||||
$allowedBlock = selfserve_eligibility_route_block($route, 'get', '/department/selfserve/vehicle/allowed');
|
$allowedBlock = selfserve_eligibility_route_block($route, 'get', '/department/selfserve/vehicle/allowed');
|
||||||
$assertBlock = selfserve_eligibility_method_block($route, 'private function assertLaneAccess(');
|
$assertBlock = selfserve_eligibility_method_block($route, 'private function assertLaneAccess(object $user, int $laneId): department_lanes_o');
|
||||||
|
|
||||||
expect($allowedBlock)->toContain('$lane = $this->assertLaneAccess($user, $lane_id, $has_global, $has_own);')
|
expect($allowedBlock)->toContain('$lane = $this->assertLaneAccess($user, $lane_id);')
|
||||||
->and($allowedBlock)->toContain('previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)')
|
->and($allowedBlock)->toContain('previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)')
|
||||||
->and(strpos($allowedBlock, '$lane = $this->assertLaneAccess($user, $lane_id, $has_global, $has_own);'))
|
->and(strpos($allowedBlock, '$lane = $this->assertLaneAccess($user, $lane_id);'))
|
||||||
->toBeLessThan(strpos($allowedBlock, 'previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)'));
|
->toBeLessThan(strpos($allowedBlock, 'previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)'));
|
||||||
|
|
||||||
expect($assertBlock)->toContain('bool $hasGlobalPermission = true')
|
expect($assertBlock)->toContain('$authorized_department_ids = array_values(array_filter(')
|
||||||
->and($assertBlock)->toContain('bool $hasOwnPermission = false')
|
->and($assertBlock)->toContain('array_map(\'intval\', (array)$user->getGroup()->getDepartments())')
|
||||||
->and($assertBlock)->toContain('if ($hasGlobalPermission && $this->userHasLaneDepartmentAccess($user, $lane))')
|
->and($assertBlock)->toContain('if (!in_array($lane_department_id, $authorized_department_ids, true))')
|
||||||
->and($assertBlock)->toContain('if ($hasOwnPermission && $this->isCustomerSelfServeLaneEnabled($lane))')
|
|
||||||
->and($assertBlock)->toContain('$this->forbidDepartmentAccess($lane_department_id);')
|
->and($assertBlock)->toContain('$this->forbidDepartmentAccess($lane_department_id);')
|
||||||
->and($assertBlock)->toContain('$response->forbidden([$elevatedPermission]);');
|
->and($assertBlock)->not->toContain('if ($hasGlobalPermission)');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ class SelfserveLaneStartEntranceTimeoutHarness
|
|||||||
public ?\Throwable $reportedTimeout = null;
|
public ?\Throwable $reportedTimeout = null;
|
||||||
public ?selfserve_lane_state $laneState = null;
|
public ?selfserve_lane_state $laneState = null;
|
||||||
public int $cleanerRelayCalls = 0;
|
public int $cleanerRelayCalls = 0;
|
||||||
public int $programPickerRelayCalls = 0;
|
|
||||||
public int $machineRelayCalls = 0;
|
public int $machineRelayCalls = 0;
|
||||||
/** @var array<int,string> */
|
|
||||||
public array $relayEvents = [];
|
|
||||||
|
|
||||||
public function open(selfserve_lane_port $port, ?int $toggle_after_seconds = null): bool
|
public function open(selfserve_lane_port $port, ?int $toggle_after_seconds = null): bool
|
||||||
{
|
{
|
||||||
@@ -50,28 +47,11 @@ class SelfserveLaneStartEntranceTimeoutHarness
|
|||||||
protected function turnOnCleanerRelayForWashStart(): void
|
protected function turnOnCleanerRelayForWashStart(): void
|
||||||
{
|
{
|
||||||
$this->cleanerRelayCalls++;
|
$this->cleanerRelayCalls++;
|
||||||
$this->relayEvents[] = 'cleaner:on';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
protected function setMachineRelayStatusForWashStart(): void
|
||||||
{
|
{
|
||||||
unset($arguments);
|
|
||||||
$this->programPickerRelayCalls++;
|
|
||||||
$this->relayEvents[] = 'program_picker:selected_service';
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function setProgramPickerRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
|
||||||
{
|
|
||||||
unset($arguments);
|
|
||||||
$this->programPickerRelayCalls++;
|
|
||||||
$this->relayEvents[] = 'program_picker:eligibility_sync';
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function setMachineRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
|
|
||||||
{
|
|
||||||
unset($arguments);
|
|
||||||
$this->machineRelayCalls++;
|
$this->machineRelayCalls++;
|
||||||
$this->relayEvents[] = 'machine:sync';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function runEntranceOpenForStart(): void
|
public function runEntranceOpenForStart(): void
|
||||||
@@ -110,54 +90,28 @@ it('parses deferred relay side effects on start command arguments', function ():
|
|||||||
$arguments = (new selfserve_lane_command_arguments())->setParameters([
|
$arguments = (new selfserve_lane_command_arguments())->setParameters([
|
||||||
'license_plate' => 'ab12345',
|
'license_plate' => 'ab12345',
|
||||||
'customer_number' => 12345679,
|
'customer_number' => 12345679,
|
||||||
'wash_type' => 'Manual',
|
|
||||||
'defer_relay_side_effects' => true,
|
'defer_relay_side_effects' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect($arguments->license_plate)->toBe('AB12345');
|
expect($arguments->license_plate)->toBe('AB12345');
|
||||||
expect($arguments->customer_number)->toBe(12345679);
|
expect($arguments->customer_number)->toBe(12345679);
|
||||||
expect($arguments->wash_mode)->toBe('manual');
|
|
||||||
expect($arguments->defer_relay_side_effects)->toBeTrue();
|
expect($arguments->defer_relay_side_effects)->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses wash mode aliases on start command arguments', function (): void {
|
it('skips cleaner and machine relay side effects when start asks to defer them', function (): void {
|
||||||
$arguments = (new selfserve_lane_command_arguments())->setParameters([
|
|
||||||
'wash_mode' => 'machine',
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect($arguments->wash_mode)->toBe('machine');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid wash types on start command arguments', function (): void {
|
|
||||||
expect(fn() => (new selfserve_lane_command_arguments())->setParameters([
|
|
||||||
'wash_type' => 'automatic',
|
|
||||||
]))->toThrow(\InvalidArgumentException::class, 'Invalid wash type: automatic');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('only syncs program picker from selected service when start asks to defer machine side effects', function (): void {
|
|
||||||
$lane = new SelfserveLaneStartEntranceTimeoutHarness();
|
$lane = new SelfserveLaneStartEntranceTimeoutHarness();
|
||||||
|
|
||||||
$lane->runStartRelaySideEffects(true);
|
$lane->runStartRelaySideEffects(true);
|
||||||
|
|
||||||
expect($lane->cleanerRelayCalls)->toBe(0);
|
expect($lane->cleanerRelayCalls)->toBe(0);
|
||||||
expect($lane->programPickerRelayCalls)->toBe(1);
|
|
||||||
expect($lane->machineRelayCalls)->toBe(0);
|
expect($lane->machineRelayCalls)->toBe(0);
|
||||||
expect($lane->relayEvents)->toBe([
|
|
||||||
'program_picker:selected_service',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps cleaner, program picker and machine relay side effects in order for normal start commands', function (): void {
|
it('keeps cleaner and machine relay side effects for normal start commands', function (): void {
|
||||||
$lane = new SelfserveLaneStartEntranceTimeoutHarness();
|
$lane = new SelfserveLaneStartEntranceTimeoutHarness();
|
||||||
|
|
||||||
$lane->runStartRelaySideEffects(false);
|
$lane->runStartRelaySideEffects(false);
|
||||||
|
|
||||||
expect($lane->cleanerRelayCalls)->toBe(1);
|
expect($lane->cleanerRelayCalls)->toBe(1);
|
||||||
expect($lane->programPickerRelayCalls)->toBe(1);
|
|
||||||
expect($lane->machineRelayCalls)->toBe(1);
|
expect($lane->machineRelayCalls)->toBe(1);
|
||||||
expect($lane->relayEvents)->toBe([
|
|
||||||
'cleaner:on',
|
|
||||||
'program_picker:eligibility_sync',
|
|
||||||
'machine:sync',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ class SelfserveLaneStopFlowHarness
|
|||||||
public array $openedPorts = [];
|
public array $openedPorts = [];
|
||||||
/** @var selfserve_lane_relay[] */
|
/** @var selfserve_lane_relay[] */
|
||||||
public array $turnedOffRelays = [];
|
public array $turnedOffRelays = [];
|
||||||
/** @var array<int,string> */
|
|
||||||
public array $events = [];
|
|
||||||
|
|
||||||
private selfserve_lane_status $laneStatus;
|
private selfserve_lane_status $laneStatus;
|
||||||
private selfserve_lane_mode $laneMode;
|
private selfserve_lane_mode $laneMode;
|
||||||
@@ -184,7 +182,6 @@ class SelfserveLaneStopFlowHarness
|
|||||||
public function open(selfserve_lane_port $port): bool
|
public function open(selfserve_lane_port $port): bool
|
||||||
{
|
{
|
||||||
$this->openedPorts[] = $port;
|
$this->openedPorts[] = $port;
|
||||||
$this->events[] = 'open:' . $port->name;
|
|
||||||
if ($this->openThrowable !== null) {
|
if ($this->openThrowable !== null) {
|
||||||
throw $this->openThrowable;
|
throw $this->openThrowable;
|
||||||
}
|
}
|
||||||
@@ -195,7 +192,6 @@ class SelfserveLaneStopFlowHarness
|
|||||||
public function turnOffRelay(selfserve_lane_relay $relay): bool
|
public function turnOffRelay(selfserve_lane_relay $relay): bool
|
||||||
{
|
{
|
||||||
$this->turnedOffRelays[] = $relay;
|
$this->turnedOffRelays[] = $relay;
|
||||||
$this->events[] = 'relay:' . $relay->name . ':off';
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +199,6 @@ class SelfserveLaneStopFlowHarness
|
|||||||
{
|
{
|
||||||
if ($on === false) {
|
if ($on === false) {
|
||||||
$this->turnedOffRelays[] = $relay;
|
$this->turnedOffRelays[] = $relay;
|
||||||
$this->events[] = 'relay:' . $relay->name . ':off';
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -234,7 +229,7 @@ class SelfserveLaneStopFlowHarness
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it('adds vehicle type product on STOP when the physical machine ON signal was recorded, after turning relays off before exit', function (): void {
|
it('adds vehicle type product on STOP when the physical machine ON signal was recorded, then turns off cleaner and machine relays', function (): void {
|
||||||
$lane = new SelfserveLaneStopFlowHarness(machineStartTriggered: true);
|
$lane = new SelfserveLaneStopFlowHarness(machineStartTriggered: true);
|
||||||
$args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234);
|
$args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234);
|
||||||
|
|
||||||
@@ -248,15 +243,8 @@ it('adds vehicle type product on STOP when the physical machine ON signal was re
|
|||||||
expect($lane->openedPorts)->toBe([selfserve_lane_port::EXIT]);
|
expect($lane->openedPorts)->toBe([selfserve_lane_port::EXIT]);
|
||||||
expect($lane->turnedOffRelays)->toBe([
|
expect($lane->turnedOffRelays)->toBe([
|
||||||
selfserve_lane_relay::MACHINE_CLEANER,
|
selfserve_lane_relay::MACHINE_CLEANER,
|
||||||
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
|
|
||||||
selfserve_lane_relay::MACHINE,
|
selfserve_lane_relay::MACHINE,
|
||||||
]);
|
]);
|
||||||
expect($lane->events)->toBe([
|
|
||||||
'relay:MACHINE_CLEANER:off',
|
|
||||||
'relay:MACHINE_PROGRAM_PICKER:off',
|
|
||||||
'relay:MACHINE:off',
|
|
||||||
'open:EXIT',
|
|
||||||
]);
|
|
||||||
expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE);
|
expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -278,14 +266,8 @@ it('skips vehicle type product add when no physical machine ON signal was record
|
|||||||
expect($lane->lastVehicleTypeProductDecision)->toBeFalse();
|
expect($lane->lastVehicleTypeProductDecision)->toBeFalse();
|
||||||
expect($lane->programSelectorStatusReads)->toBe(0);
|
expect($lane->programSelectorStatusReads)->toBe(0);
|
||||||
expect($lane->turnedOffRelays)->toBe([
|
expect($lane->turnedOffRelays)->toBe([
|
||||||
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
|
|
||||||
selfserve_lane_relay::MACHINE,
|
selfserve_lane_relay::MACHINE,
|
||||||
]);
|
]);
|
||||||
expect($lane->events)->toBe([
|
|
||||||
'relay:MACHINE_PROGRAM_PICKER:off',
|
|
||||||
'relay:MACHINE:off',
|
|
||||||
'open:EXIT',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not use selector relay online status as machine-wash billing evidence', function (): void {
|
it('does not use selector relay online status as machine-wash billing evidence', function (): void {
|
||||||
@@ -319,15 +301,8 @@ it('continues STOP when exit relay dispatch times out ambiguously', function ():
|
|||||||
expect($lane->vehicleTypeProductAddCalls)->toBe(1);
|
expect($lane->vehicleTypeProductAddCalls)->toBe(1);
|
||||||
expect($lane->turnedOffRelays)->toBe([
|
expect($lane->turnedOffRelays)->toBe([
|
||||||
selfserve_lane_relay::MACHINE_CLEANER,
|
selfserve_lane_relay::MACHINE_CLEANER,
|
||||||
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
|
|
||||||
selfserve_lane_relay::MACHINE,
|
selfserve_lane_relay::MACHINE,
|
||||||
]);
|
]);
|
||||||
expect($lane->events)->toBe([
|
|
||||||
'relay:MACHINE_CLEANER:off',
|
|
||||||
'relay:MACHINE_PROGRAM_PICKER:off',
|
|
||||||
'relay:MACHINE:off',
|
|
||||||
'open:EXIT',
|
|
||||||
]);
|
|
||||||
expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE);
|
expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -138,8 +138,6 @@ it('documents property gate lane commands and sanitized gate failure responses',
|
|||||||
|
|
||||||
expect($commandPathBlock)->toContain('OPEN_PROPERTY_ACCESS_GATE');
|
expect($commandPathBlock)->toContain('OPEN_PROPERTY_ACCESS_GATE');
|
||||||
expect($commandPathBlock)->toContain('OPEN_PROPERTY_EXIT_GATE');
|
expect($commandPathBlock)->toContain('OPEN_PROPERTY_EXIT_GATE');
|
||||||
expect($commandPathBlock)->toContain('wash_type:');
|
|
||||||
expect($commandPathBlock)->toContain('wash_mode:');
|
|
||||||
expect($commandPathBlock)->toContain('Command execution failed');
|
expect($commandPathBlock)->toContain('Command execution failed');
|
||||||
expect($commandPathBlock)->toContain('Failed to execute command: Failed to open property access gate.');
|
expect($commandPathBlock)->toContain('Failed to execute command: Failed to open property access gate.');
|
||||||
|
|
||||||
|
|||||||
-164
@@ -1,164 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
app_require('modules/selfserve/traits/selfserve_lane_command_t.php');
|
|
||||||
app_require('modules/selfserve/classes/selfserve_lane_command_arguments.php');
|
|
||||||
app_require('modules/selfserve/helpers/selfserve_lane_relay.php');
|
|
||||||
|
|
||||||
use modules\selfserve\classes\selfserve_lane_command_arguments;
|
|
||||||
use modules\selfserve\traits\selfserve_lane_command_t;
|
|
||||||
|
|
||||||
class SelfserveProgramPickerSelectionValueFake
|
|
||||||
{
|
|
||||||
public function __construct(private readonly string $value) {}
|
|
||||||
|
|
||||||
public function value(): string
|
|
||||||
{
|
|
||||||
return $this->value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SelfserveProgramPickerSelectionDepartmentLaneFake
|
|
||||||
{
|
|
||||||
public SelfserveProgramPickerSelectionValueFake $relay_machine_id;
|
|
||||||
public SelfserveProgramPickerSelectionValueFake $relay_machine_program_picker_id;
|
|
||||||
public SelfserveProgramPickerSelectionValueFake $relay_machine_cleaner_id;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->relay_machine_id = new SelfserveProgramPickerSelectionValueFake('relay-machine');
|
|
||||||
$this->relay_machine_program_picker_id = new SelfserveProgramPickerSelectionValueFake('relay-program-picker');
|
|
||||||
$this->relay_machine_cleaner_id = new SelfserveProgramPickerSelectionValueFake('relay-cleaner');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SelfserveProgramPickerSelectionHarness
|
|
||||||
{
|
|
||||||
use selfserve_lane_command_t;
|
|
||||||
|
|
||||||
public const CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES = 'allowed_services';
|
|
||||||
|
|
||||||
public int $id = 881;
|
|
||||||
public object $department_lane;
|
|
||||||
public int $licensePlateReads = 0;
|
|
||||||
public int $customerNumberReads = 0;
|
|
||||||
public int $availabilityChecks = 0;
|
|
||||||
public bool $machineAvailable = true;
|
|
||||||
/** @var array<int,bool> */
|
|
||||||
public array $programPickerWrites = [];
|
|
||||||
/** @var array<string,mixed> */
|
|
||||||
private array $laneCache = [];
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->department_lane = new SelfserveProgramPickerSelectionDepartmentLaneFake();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLaneCache(int $lane_id, string $key): mixed
|
|
||||||
{
|
|
||||||
return $this->laneCache[$key . '_' . $lane_id] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setSelectedServices(array $services): void
|
|
||||||
{
|
|
||||||
$this->laneCache[self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES . '_' . $this->id] = $services;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLicensePlate(): string
|
|
||||||
{
|
|
||||||
$this->licensePlateReads++;
|
|
||||||
return 'AB12345';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCustomerNumber(): int
|
|
||||||
{
|
|
||||||
$this->customerNumberReads++;
|
|
||||||
return 12345678;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setMachineProgramPickerRelayStatusHard(bool $on): bool
|
|
||||||
{
|
|
||||||
$this->programPickerWrites[] = $on;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function runDeferredStartRelaySideEffects(?string $washType = null): void
|
|
||||||
{
|
|
||||||
$arguments = (new selfserve_lane_command_arguments())
|
|
||||||
->setDeferRelaySideEffects(true)
|
|
||||||
->setWashMode($washType);
|
|
||||||
$this->runRelaySideEffectsForWashStart($arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function runNormalStartProgramPickerRelay(?string $washType = null): void
|
|
||||||
{
|
|
||||||
$arguments = (new selfserve_lane_command_arguments())->setWashMode($washType);
|
|
||||||
$this->setProgramPickerRelayStatusForWashStart($arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resolveStartWashMode(?string $washType = null): string
|
|
||||||
{
|
|
||||||
$arguments = (new selfserve_lane_command_arguments())->setWashMode($washType);
|
|
||||||
return $this->resolveSelfServeActionWashModeForStart($arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function isMachineWashSelectedAndAvailableForStart(?selfserve_lane_command_arguments $arguments = null): bool
|
|
||||||
{
|
|
||||||
unset($arguments);
|
|
||||||
$this->availabilityChecks++;
|
|
||||||
return $this->machineAvailable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('turns off the program picker on deferred start when the frontend selected manual wash', function (): void {
|
|
||||||
$lane = new SelfserveProgramPickerSelectionHarness();
|
|
||||||
$lane->setSelectedServices([]);
|
|
||||||
|
|
||||||
$lane->runDeferredStartRelaySideEffects('Manual');
|
|
||||||
|
|
||||||
expect($lane->programPickerWrites)->toBe([false]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not let backend machine eligibility override a frontend manual wash selection', function (): void {
|
|
||||||
$lane = new SelfserveProgramPickerSelectionHarness();
|
|
||||||
$lane->setSelectedServices(['MACHINE']);
|
|
||||||
|
|
||||||
$lane->runDeferredStartRelaySideEffects('Manual');
|
|
||||||
|
|
||||||
expect($lane->licensePlateReads)->toBe(0)
|
|
||||||
->and($lane->customerNumberReads)->toBe(0)
|
|
||||||
->and($lane->programPickerWrites)->toBe([false])
|
|
||||||
->and($lane->resolveStartWashMode('Manual'))->toBe('manual');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not infer program picker enablement from machine service without a customer machine selection', function (): void {
|
|
||||||
$lane = new SelfserveProgramPickerSelectionHarness();
|
|
||||||
$lane->setSelectedServices(['MACHINE']);
|
|
||||||
|
|
||||||
$lane->runDeferredStartRelaySideEffects();
|
|
||||||
$lane->runNormalStartProgramPickerRelay();
|
|
||||||
|
|
||||||
expect($lane->programPickerWrites)->toBe([false, false])
|
|
||||||
->and($lane->availabilityChecks)->toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps normal start program picker off when the customer selected manual wash', function (): void {
|
|
||||||
$lane = new SelfserveProgramPickerSelectionHarness();
|
|
||||||
$lane->setSelectedServices(['MACHINE']);
|
|
||||||
|
|
||||||
$lane->runNormalStartProgramPickerRelay('Manual');
|
|
||||||
|
|
||||||
expect($lane->programPickerWrites)->toBe([false])
|
|
||||||
->and($lane->availabilityChecks)->toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('honors a frontend machine wash selection when machine service is selected', function (): void {
|
|
||||||
$lane = new SelfserveProgramPickerSelectionHarness();
|
|
||||||
$lane->setSelectedServices(['MACHINE']);
|
|
||||||
|
|
||||||
$lane->runDeferredStartRelaySideEffects('Machine');
|
|
||||||
$lane->runNormalStartProgramPickerRelay('Machine');
|
|
||||||
|
|
||||||
expect($lane->programPickerWrites)->toBe([true, true])
|
|
||||||
->and($lane->availabilityChecks)->toBe(1)
|
|
||||||
->and($lane->resolveStartWashMode('Machine'))->toBe('machine');
|
|
||||||
});
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
app_require('modules/selfserve/classes/selfserve_wash_flow.php');
|
|
||||||
|
|
||||||
use modules\selfserve\classes\selfserve_wash_flow;
|
|
||||||
|
|
||||||
class SelfservePublishedConfigLaneWildcardHarness extends selfserve_wash_flow
|
|
||||||
{
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function questionsFor(int $departmentId, int $laneId, int $vehicleTypeId, array $config): array
|
|
||||||
{
|
|
||||||
return $this->loadQuestions($departmentId, $laneId, $vehicleTypeId, $config);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function conditionsFor(int $departmentId, int $laneId, int $vehicleTypeId, array $config): array
|
|
||||||
{
|
|
||||||
return $this->loadConditions($departmentId, $laneId, $vehicleTypeId, null, $config);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tasksFor(int $departmentId, int $laneId, int $vehicleTypeId, array $config): array
|
|
||||||
{
|
|
||||||
return $this->loadTasks($departmentId, $laneId, $vehicleTypeId, null, $config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('applies department product rows with lane zero to all lanes in published v2 configs', function (): void {
|
|
||||||
$flow = new SelfservePublishedConfigLaneWildcardHarness();
|
|
||||||
$config = [
|
|
||||||
'schema_version' => 2,
|
|
||||||
'questions' => [
|
|
||||||
['id' => 1, 'department' => 6, 'lane' => 0, 'product' => 1, 'question' => 'Wildcard question'],
|
|
||||||
['id' => 2, 'department' => 6, 'lane' => 99, 'product' => 1, 'question' => 'Other lane question'],
|
|
||||||
],
|
|
||||||
'conditions' => [
|
|
||||||
['id' => 11, 'department' => 6, 'lane' => 0, 'product' => 1, 'machine_type_id' => null, 'expression' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE']],
|
|
||||||
['id' => 12, 'department' => 6, 'lane' => 99, 'product' => 1, 'machine_type_id' => null, 'expression' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE']],
|
|
||||||
],
|
|
||||||
'tasks' => [
|
|
||||||
['id' => 21, 'department' => 6, 'lane' => 0, 'product' => 1, 'machine_type_id' => null, 'task' => 'Wildcard task', 'services' => ['MACHINE'], 'buttons' => [], 'gate_type' => 'ALWAYS', 'gate_ref_id' => null],
|
|
||||||
['id' => 22, 'department' => 6, 'lane' => 99, 'product' => 1, 'machine_type_id' => null, 'task' => 'Other lane task', 'services' => ['MACHINE'], 'buttons' => [], 'gate_type' => 'ALWAYS', 'gate_ref_id' => null],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(array_column($flow->questionsFor(6, 12, 1, $config), 'id'))->toBe([1]);
|
|
||||||
expect(array_column($flow->conditionsFor(6, 12, 1, $config), 'id'))->toBe([11]);
|
|
||||||
expect(array_column($flow->tasksFor(6, 12, 1, $config), 'id'))->toBe([21]);
|
|
||||||
});
|
|
||||||
@@ -84,17 +84,6 @@ it('wires lane-level self-serve toggles through lane APIs, guest payloads, and e
|
|||||||
->and($edgeWorkspace)->toContain("(int)(\$selfServe['ready_lanes'] ?? 0) < (int)(\$selfServe['enabled_lanes'] ?? 0)");
|
->and($edgeWorkspace)->toContain("(int)(\$selfServe['ready_lanes'] ?? 0) < (int)(\$selfServe['enabled_lanes'] ?? 0)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sources guest lane product availability from the published v2 self-serve config before legacy tasks', function (): void {
|
|
||||||
$laneObject = file_get_contents(app_path('objects/department_lanes_o.php'));
|
|
||||||
|
|
||||||
expect($laneObject)->not->toBeFalse()
|
|
||||||
->and($laneObject)->toContain('getPublishedSelfServeLaneProducts')
|
|
||||||
->and($laneObject)->toContain('selfserve_config_versioning')
|
|
||||||
->and($laneObject)->toContain('getPublishedV2Config')
|
|
||||||
->and($laneObject)->toContain("\$taskLane !== 0 && \$taskLane !== \$laneId")
|
|
||||||
->and($laneObject)->toContain('department_selfserve_tasks_o::getLaneProducts');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wires self-serve config draft/publish/rollback lifecycle endpoints', function (): void {
|
it('wires self-serve config draft/publish/rollback lifecycle endpoints', function (): void {
|
||||||
$configRoute = file_get_contents(app_path('routes/departmentSelfserveConfigVersionsRoute.php'));
|
$configRoute = file_get_contents(app_path('routes/departmentSelfserveConfigVersionsRoute.php'));
|
||||||
|
|
||||||
@@ -181,10 +170,8 @@ it('keeps legacy self-serve CRUD routes syncing canonical drafts', function ():
|
|||||||
|
|
||||||
it('wires machine relay status get and set endpoints', function (): void {
|
it('wires machine relay status get and set endpoints', function (): void {
|
||||||
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
|
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
|
||||||
$relayController = file_get_contents(app_path('modules/selfserve/traits/selfserve_lane_relay_controller_t.php'));
|
|
||||||
|
|
||||||
expect($moduleSelfServeRoute)->not->toBeFalse();
|
expect($moduleSelfServeRoute)->not->toBeFalse();
|
||||||
expect($relayController)->not->toBeFalse();
|
|
||||||
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine/status');
|
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine/status');
|
||||||
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine/set');
|
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine/set');
|
||||||
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine_program_picker/status');
|
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine_program_picker/status');
|
||||||
@@ -198,9 +185,6 @@ it('wires machine relay status get and set endpoints', function (): void {
|
|||||||
expect($moduleSelfServeRoute)->toContain('getMachineCleanerRelayStatus');
|
expect($moduleSelfServeRoute)->toContain('getMachineCleanerRelayStatus');
|
||||||
expect($moduleSelfServeRoute)->toContain('setMachineCleanerRelayStatus');
|
expect($moduleSelfServeRoute)->toContain('setMachineCleanerRelayStatus');
|
||||||
expect($moduleSelfServeRoute)->toContain('setMachineCleanerRelayStatusHard(true)');
|
expect($moduleSelfServeRoute)->toContain('setMachineCleanerRelayStatusHard(true)');
|
||||||
expect($relayController)->toContain('markLatestSelfServeSessionRelayEnabledForLane');
|
|
||||||
expect($relayController)->toContain('selectLatestOpenByLane(');
|
|
||||||
expect($relayController)->toContain('$session->markRelayEnabled();');
|
|
||||||
expect($moduleSelfServeRoute)->toContain('applyShellyTransportOverride($lane)');
|
expect($moduleSelfServeRoute)->toContain('applyShellyTransportOverride($lane)');
|
||||||
expect($moduleSelfServeRoute)->toContain("'transport' => \$this->requestedShellyTransportOverride()");
|
expect($moduleSelfServeRoute)->toContain("'transport' => \$this->requestedShellyTransportOverride()");
|
||||||
expect($moduleSelfServeRoute)->toContain('buildRelayStatusResponse');
|
expect($moduleSelfServeRoute)->toContain('buildRelayStatusResponse');
|
||||||
@@ -256,11 +240,6 @@ it('wires allowed services route through machine relay visibility sync', functio
|
|||||||
|
|
||||||
expect($moduleSelfServeRoute)->not->toBeFalse();
|
expect($moduleSelfServeRoute)->not->toBeFalse();
|
||||||
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/services/allowed');
|
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/services/allowed');
|
||||||
expect($moduleSelfServeRoute)->toContain('selfserve_wash_session_tasks_o');
|
|
||||||
expect($moduleSelfServeRoute)->toContain('selectLatestOpenByLane');
|
|
||||||
expect($moduleSelfServeRoute)->toContain('$session_task_services[$task_id]');
|
|
||||||
expect($moduleSelfServeRoute)->toContain('publishedConfigTaskServicesForLane');
|
|
||||||
expect($moduleSelfServeRoute)->toContain('$published_config_task_services[$tid]');
|
|
||||||
expect($moduleSelfServeRoute)->toContain('setAllowedServicesFromVisibleTasks($allowed_services)');
|
expect($moduleSelfServeRoute)->toContain('setAllowedServicesFromVisibleTasks($allowed_services)');
|
||||||
expect($moduleSelfServeRoute)->not->toContain('syncMachineRelayFromVisibleServices($allowed_services, true)');
|
expect($moduleSelfServeRoute)->not->toContain('syncMachineRelayFromVisibleServices($allowed_services, true)');
|
||||||
expect($moduleSelfServeRoute)->toContain("'relay_sync' => \$relay_sync");
|
expect($moduleSelfServeRoute)->toContain("'relay_sync' => \$relay_sync");
|
||||||
@@ -295,9 +274,6 @@ it('wires self-serve property gate command permissions', function (): void {
|
|||||||
expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_command_execute_open_property_access_gate');
|
expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_command_execute_open_property_access_gate');
|
||||||
expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_command_execute_open_property_exit_gate');
|
expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_command_execute_open_property_exit_gate');
|
||||||
expect($moduleSelfServeRoute)->toContain('Failed to execute self-serve property gate command');
|
expect($moduleSelfServeRoute)->toContain('Failed to execute self-serve property gate command');
|
||||||
expect($moduleSelfServeRoute)->toContain('$this->canCustomerUsePropertyGateForLane($lane, $customer_number)');
|
|
||||||
expect($moduleSelfServeRoute)->toContain('$this->isOwnCustomerContext($customer_number)');
|
|
||||||
expect($moduleSelfServeRoute)->toContain('$this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number)');
|
|
||||||
|
|
||||||
expect($commandTrait)->not->toBeFalse();
|
expect($commandTrait)->not->toBeFalse();
|
||||||
expect($commandTrait)->toContain('Failed to open property access gate.');
|
expect($commandTrait)->toContain('Failed to open property access gate.');
|
||||||
@@ -448,8 +424,7 @@ it('wires vehicle type override into self-serve preview and synchronization rout
|
|||||||
expect($vehicleConditionsRoute)->toContain('normalizeVehicleTypeOverride');
|
expect($vehicleConditionsRoute)->toContain('normalizeVehicleTypeOverride');
|
||||||
expect($vehicleConditionsRoute)->toContain('shouldRefreshSummaryForVehicleType');
|
expect($vehicleConditionsRoute)->toContain('shouldRefreshSummaryForVehicleType');
|
||||||
expect($vehicleConditionsRoute)->toContain('previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)');
|
expect($vehicleConditionsRoute)->toContain('previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)');
|
||||||
expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false, [');
|
expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false)');
|
||||||
expect($vehicleConditionsRoute)->toContain("'create_session' => false");
|
|
||||||
expect($vehicleConditionsRoute)->toContain('requestBooleanFlag');
|
expect($vehicleConditionsRoute)->toContain('requestBooleanFlag');
|
||||||
expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state)');
|
expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state)');
|
||||||
expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane_id, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state)');
|
expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane_id, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state)');
|
||||||
@@ -485,11 +460,9 @@ it('keeps read-only self-serve preview and summary refreshes from touching relay
|
|||||||
$vehicleConditionsRoute = file_get_contents(app_path('routes/departmentSelfserveVehicleConditionsRoute.php'));
|
$vehicleConditionsRoute = file_get_contents(app_path('routes/departmentSelfserveVehicleConditionsRoute.php'));
|
||||||
|
|
||||||
expect($vehicleConditionsRoute)->not->toBeFalse();
|
expect($vehicleConditionsRoute)->not->toBeFalse();
|
||||||
expect($vehicleConditionsRoute)->toContain('$flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false, [');
|
expect($vehicleConditionsRoute)->toContain('$flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false);');
|
||||||
expect($vehicleConditionsRoute)->toContain("'create_session' => false");
|
|
||||||
expect($vehicleConditionsRoute)->toContain('$summary = $flow->synchronizeSession(');
|
expect($vehicleConditionsRoute)->toContain('$summary = $flow->synchronizeSession(');
|
||||||
expect($vehicleConditionsRoute)->toContain('$summary = $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false, [');
|
expect($vehicleConditionsRoute)->toContain('$summary = $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false);');
|
||||||
expect($vehicleConditionsRoute)->toContain('$summary = $flow->getLatestSessionSummary($lane_id, $reg);');
|
|
||||||
expect($vehicleConditionsRoute)->toContain('$activate_machine = $this->requestBooleanFlag(\'activate_machine\', true);');
|
expect($vehicleConditionsRoute)->toContain('$activate_machine = $this->requestBooleanFlag(\'activate_machine\', true);');
|
||||||
expect($vehicleConditionsRoute)->toContain('$sync_relay_state = $this->requestBooleanFlag(\'sync_relay_state\', true);');
|
expect($vehicleConditionsRoute)->toContain('$sync_relay_state = $this->requestBooleanFlag(\'sync_relay_state\', true);');
|
||||||
expect($vehicleConditionsRoute)->toContain('$this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);');
|
expect($vehicleConditionsRoute)->toContain('$this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);');
|
||||||
@@ -512,10 +485,6 @@ it('classifies self-serve lane command route authorization by customer product b
|
|||||||
expect($productDocs)->toContain('Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer\'s active wash in the lane department.');
|
expect($productDocs)->toContain('Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer\'s active wash in the lane department.');
|
||||||
expect($washFlow)->toContain('$payload[\'command\'] = $relayRole === \'PROPERTY_ENTRANCE\' ? \'OPEN_PROPERTY_ACCESS_GATE\' : \'OPEN_PROPERTY_EXIT_GATE\'');
|
expect($washFlow)->toContain('$payload[\'command\'] = $relayRole === \'PROPERTY_ENTRANCE\' ? \'OPEN_PROPERTY_ACCESS_GATE\' : \'OPEN_PROPERTY_EXIT_GATE\'');
|
||||||
expect($washFlow)->toContain('$signalType = \'studio_action_gate_open\'');
|
expect($washFlow)->toContain('$signalType = \'studio_action_gate_open\'');
|
||||||
expect($moduleSelfServeRoute)
|
|
||||||
->toContain('if ($command === selfserve_lane_command::START)')
|
|
||||||
->toContain('(new selfserve_wash_flow())->synchronizeSession(')
|
|
||||||
->toContain('Failed to persist self-serve START session');
|
|
||||||
|
|
||||||
$commandCases = selfserve_lane_command_route_cases($moduleSelfServeRoute);
|
$commandCases = selfserve_lane_command_route_cases($moduleSelfServeRoute);
|
||||||
|
|
||||||
|
|||||||
-112
@@ -9,118 +9,6 @@ it('adds dynamic_images_vehicle_type column for legacy selfserve wash session ta
|
|||||||
expect($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_session_tasks ADD COLUMN dynamic_images_vehicle_type INT NULL AFTER buttons');
|
expect($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_session_tasks ADD COLUMN dynamic_images_vehicle_type INT NULL AFTER buttons');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('widens self-serve task descriptions for generated workbook instructions', function (): void {
|
|
||||||
$bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php'));
|
|
||||||
|
|
||||||
expect($bootstrapContent)->not->toBeFalse();
|
|
||||||
expect($bootstrapContent)->toContain('description TEXT NULL');
|
|
||||||
expect($bootstrapContent)->toContain('ensureColumnDataType(');
|
|
||||||
expect($bootstrapContent)->toContain("['text', 'mediumtext', 'longtext']");
|
|
||||||
expect($bootstrapContent)->toContain('ALTER TABLE department_selfserve_tasks MODIFY COLUMN description TEXT NULL AFTER task');
|
|
||||||
expect($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_session_tasks MODIFY COLUMN description TEXT NULL AFTER task_text');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('alters legacy bounded description columns to text', function (): void {
|
|
||||||
$hadDb = array_key_exists('db', $GLOBALS);
|
|
||||||
$previousDb = $GLOBALS['db'] ?? null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
foreach (['varchar', 'tinytext'] as $legacyType) {
|
|
||||||
$fakeDb = new class ($legacyType) {
|
|
||||||
public array $queries = [];
|
|
||||||
|
|
||||||
public function __construct(private readonly string $dataType)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function escape_string(string $value): string
|
|
||||||
{
|
|
||||||
return addslashes($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDatabase(): string
|
|
||||||
{
|
|
||||||
return 'test_db';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function query(string $sql): object|bool
|
|
||||||
{
|
|
||||||
$this->queries[] = $sql;
|
|
||||||
if (str_contains($sql, 'information_schema.COLUMNS')) {
|
|
||||||
return new class ($this->dataType) {
|
|
||||||
public function __construct(private readonly string $dataType)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function fetch_assoc(): array
|
|
||||||
{
|
|
||||||
return ['DATA_TYPE' => $this->dataType];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
$GLOBALS['db'] = $fakeDb;
|
|
||||||
|
|
||||||
\classes\selfserve_schema_bootstrap::ensureColumnDataType(
|
|
||||||
'selfserve_wash_session_tasks',
|
|
||||||
'description',
|
|
||||||
['text', 'mediumtext', 'longtext'],
|
|
||||||
'ALTER TABLE selfserve_wash_session_tasks MODIFY COLUMN description TEXT NULL AFTER task_text'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($fakeDb->queries)->toContain('ALTER TABLE selfserve_wash_session_tasks MODIFY COLUMN description TEXT NULL AFTER task_text');
|
|
||||||
}
|
|
||||||
|
|
||||||
$textDb = new class {
|
|
||||||
public array $queries = [];
|
|
||||||
|
|
||||||
public function escape_string(string $value): string
|
|
||||||
{
|
|
||||||
return addslashes($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDatabase(): string
|
|
||||||
{
|
|
||||||
return 'test_db';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function query(string $sql): object|bool
|
|
||||||
{
|
|
||||||
$this->queries[] = $sql;
|
|
||||||
if (str_contains($sql, 'information_schema.COLUMNS')) {
|
|
||||||
return new class {
|
|
||||||
public function fetch_assoc(): array
|
|
||||||
{
|
|
||||||
return ['DATA_TYPE' => 'text'];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
$GLOBALS['db'] = $textDb;
|
|
||||||
|
|
||||||
\classes\selfserve_schema_bootstrap::ensureColumnDataType(
|
|
||||||
'selfserve_wash_session_tasks',
|
|
||||||
'description',
|
|
||||||
['text', 'mediumtext', 'longtext'],
|
|
||||||
'ALTER TABLE selfserve_wash_session_tasks MODIFY COLUMN description TEXT NULL AFTER task_text'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(implode("\n", $textDb->queries))->not->toContain('ALTER TABLE selfserve_wash_session_tasks MODIFY COLUMN description TEXT NULL AFTER task_text');
|
|
||||||
} finally {
|
|
||||||
if ($hadDb) {
|
|
||||||
$GLOBALS['db'] = $previousDb;
|
|
||||||
} else {
|
|
||||||
unset($GLOBALS['db']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('adds wash_started_at column for legacy selfserve wash session schemas', function (): void {
|
it('adds wash_started_at column for legacy selfserve wash session schemas', function (): void {
|
||||||
$bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php'));
|
$bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php'));
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
it('serializes self-serve session synchronization before reading or creating open sessions', function (): void {
|
|
||||||
$washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php'));
|
|
||||||
|
|
||||||
expect($washFlow)->not->toBeFalse()
|
|
||||||
->and($washFlow)->toContain('withSessionMutationLock')
|
|
||||||
->and($washFlow)->toContain('sessionMutationLockKey')
|
|
||||||
->and($washFlow)->toContain('set_if_absent_with_expiration')
|
|
||||||
->and($washFlow)->toContain('GET_LOCK');
|
|
||||||
|
|
||||||
$syncOffset = strpos($washFlow, 'public function synchronizeSession');
|
|
||||||
expect($syncOffset)->not->toBeFalse();
|
|
||||||
|
|
||||||
$syncMethod = substr($washFlow, (int)$syncOffset, 9000);
|
|
||||||
$snapshotOffset = strpos($syncMethod, '$snapshot = $this->buildEligibilitySnapshot');
|
|
||||||
$lockOffset = strpos($syncMethod, '$mutationResult = $this->withSessionMutationLock');
|
|
||||||
$findOffset = strpos($syncMethod, '$session = $this->findLatestOpenSession');
|
|
||||||
$addOffset = strpos($syncMethod, '$session = (new selfserve_wash_sessions_o())->add');
|
|
||||||
$relayOffset = strpos($syncMethod, '$this->syncMachineRelayFromVisibleServices');
|
|
||||||
|
|
||||||
expect($snapshotOffset)->not->toBeFalse()
|
|
||||||
->and($lockOffset)->not->toBeFalse()
|
|
||||||
->and($findOffset)->not->toBeFalse()
|
|
||||||
->and($addOffset)->not->toBeFalse()
|
|
||||||
->and($relayOffset)->not->toBeFalse()
|
|
||||||
->and($snapshotOffset)->toBeLessThan($lockOffset)
|
|
||||||
->and($lockOffset)->toBeLessThan($findOffset)
|
|
||||||
->and($findOffset)->toBeLessThan($addOffset)
|
|
||||||
->and($addOffset)->toBeLessThan($relayOffset);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes self-serve wash sessions with an atomic open-session guard', function (): void {
|
|
||||||
$sessionObject = file_get_contents(app_path('objects/selfserve_wash_sessions_o.php'));
|
|
||||||
$washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php'));
|
|
||||||
|
|
||||||
expect($sessionObject)->not->toBeFalse()
|
|
||||||
->and($sessionObject)->toContain('markCompletedIfOpen')
|
|
||||||
->and($sessionObject)->toContain('markForceStoppedIfOpen')
|
|
||||||
->and($sessionObject)->toContain('AND `completed_at` IS NULL')
|
|
||||||
->and($sessionObject)->toContain('AND UPPER(TRIM(`status`)) NOT IN ($terminalStatuses)');
|
|
||||||
|
|
||||||
expect($washFlow)->not->toBeFalse()
|
|
||||||
->and($washFlow)->toContain('if (!$session->markCompletedIfOpen($orderId))')
|
|
||||||
->and($washFlow)->toContain('if (!$session->markForceStoppedIfOpen($orderId, $eventPayload))');
|
|
||||||
});
|
|
||||||
@@ -26,54 +26,6 @@ function selfserve_virtual_hardware_without_constructor(): selfserve_virtual_har
|
|||||||
return $reflection->newInstanceWithoutConstructor();
|
return $reflection->newInstanceWithoutConstructor();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selfserve_question_tree_simulator(int $questionCount): callable
|
|
||||||
{
|
|
||||||
return function (array $overrides) use ($questionCount): array {
|
|
||||||
$answers = [];
|
|
||||||
foreach ($overrides as $entry) {
|
|
||||||
$answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$questions = [];
|
|
||||||
foreach (range(1, $questionCount) as $questionId) {
|
|
||||||
$questions[] = [
|
|
||||||
'id' => $questionId,
|
|
||||||
'node_id' => 'question:' . $questionId,
|
|
||||||
'label' => 'Question ' . $questionId,
|
|
||||||
'visible' => true,
|
|
||||||
'answer' => $answers[$questionId] ?? null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$complete = count($answers) === $questionCount;
|
|
||||||
$allowed = $complete && !in_array(false, $answers, true);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'allowed' => $allowed,
|
|
||||||
'questions' => [],
|
|
||||||
'tasks' => $allowed ? [
|
|
||||||
['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => ['start']],
|
|
||||||
] : [],
|
|
||||||
'allowed_services' => $allowed ? ['MACHINE'] : [],
|
|
||||||
'debug' => [
|
|
||||||
'questions' => $questions,
|
|
||||||
'tasks' => [
|
|
||||||
[
|
|
||||||
'id' => 41,
|
|
||||||
'node_id' => 'task:41',
|
|
||||||
'label' => 'Start machine',
|
|
||||||
'active' => $allowed,
|
|
||||||
'services' => ['MACHINE'],
|
|
||||||
'buttons' => ['start'],
|
|
||||||
'order_priority' => 1,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'signal_timeline' => [],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
it('serializes questions, conditions, tasks, scopes, and gateways into one graph', function (): void {
|
it('serializes questions, conditions, tasks, scopes, and gateways into one graph', function (): void {
|
||||||
$service = selfserve_studio_graph_without_constructor();
|
$service = selfserve_studio_graph_without_constructor();
|
||||||
|
|
||||||
@@ -1369,20 +1321,52 @@ it('truncates path outcome projection when the state cap is reached', function (
|
|||||||
->and($projection['warnings'][0])->toContain('truncated at 2 explored state');
|
->and($projection['warnings'][0])->toContain('truncated at 2 explored state');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns more than 200 projected path cases by default', function (): void {
|
it('applies default caps for wide question trees and reports progress', function (): void {
|
||||||
$service = selfserve_studio_graph_without_constructor();
|
$service = selfserve_studio_graph_without_constructor();
|
||||||
$projection = $service->projectPathOutcomesFromSimulator(selfserve_question_tree_simulator(8));
|
$simulate = function (array $overrides): array {
|
||||||
|
$answers = [];
|
||||||
|
foreach ($overrides as $entry) {
|
||||||
|
$answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
expect($projection['truncated'])->toBeFalse()
|
$questions = [];
|
||||||
->and($projection['summary']['state_count'])->toBe(511)
|
foreach (range(1, 12) as $questionId) {
|
||||||
->and($projection['summary']['terminal_path_count'])->toBe(256)
|
$questions[] = [
|
||||||
->and($projection['summary']['path_sample_count'])->toBe(256)
|
'id' => $questionId,
|
||||||
->and($projection['paths'])->toHaveCount(256);
|
'node_id' => 'question:' . $questionId,
|
||||||
});
|
'label' => 'Question ' . $questionId,
|
||||||
|
'visible' => true,
|
||||||
|
'answer' => $answers[$questionId] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
it('applies the default state cap for wide question trees and reports progress', function (): void {
|
$complete = count($answers) === 12;
|
||||||
$service = selfserve_studio_graph_without_constructor();
|
$allowed = $complete && !in_array(false, $answers, true);
|
||||||
$simulate = selfserve_question_tree_simulator(12);
|
|
||||||
|
return [
|
||||||
|
'allowed' => $allowed,
|
||||||
|
'questions' => [],
|
||||||
|
'tasks' => $allowed ? [
|
||||||
|
['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => ['start']],
|
||||||
|
] : [],
|
||||||
|
'allowed_services' => $allowed ? ['MACHINE'] : [],
|
||||||
|
'debug' => [
|
||||||
|
'questions' => $questions,
|
||||||
|
'tasks' => [
|
||||||
|
[
|
||||||
|
'id' => 41,
|
||||||
|
'node_id' => 'task:41',
|
||||||
|
'label' => 'Start machine',
|
||||||
|
'active' => $allowed,
|
||||||
|
'services' => ['MACHINE'],
|
||||||
|
'buttons' => ['start'],
|
||||||
|
'order_priority' => 1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'signal_timeline' => [],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
$progressEvents = [];
|
$progressEvents = [];
|
||||||
$projection = $service->projectPathOutcomesFromSimulator($simulate, [
|
$projection = $service->projectPathOutcomesFromSimulator($simulate, [
|
||||||
@@ -1401,10 +1385,10 @@ it('applies the default state cap for wide question trees and reports progress',
|
|||||||
->and($projection['summary']['question_count'])->toBe(12)
|
->and($projection['summary']['question_count'])->toBe(12)
|
||||||
->and($projection['summary']['terminal_path_count'])->toBe(1023)
|
->and($projection['summary']['terminal_path_count'])->toBe(1023)
|
||||||
->and($projection['summary']['outcome_count'])->toBe(2)
|
->and($projection['summary']['outcome_count'])->toBe(2)
|
||||||
->and($projection['summary']['path_sample_count'])->toBe(1023)
|
->and($projection['summary']['path_sample_count'])->toBe(200)
|
||||||
->and($projection['progress']['complete'])->toBeFalse()
|
->and($projection['progress']['complete'])->toBeFalse()
|
||||||
->and($projection['progress']['percent'])->toBe(99)
|
->and($projection['progress']['percent'])->toBe(99)
|
||||||
->and($projection['paths'])->toHaveCount(1023)
|
->and($projection['paths'])->toHaveCount(200)
|
||||||
->and($projection['paths'][0]['answers'])->toHaveCount(12)
|
->and($projection['paths'][0]['answers'])->toHaveCount(12)
|
||||||
->and($projection['paths'][0]['result'])->toBe('Allowed')
|
->and($projection['paths'][0]['result'])->toBe('Allowed')
|
||||||
->and($projection['warnings'][0])->toContain('truncated at 2048 explored state')
|
->and($projection['warnings'][0])->toContain('truncated at 2048 explored state')
|
||||||
|
|||||||
@@ -79,8 +79,6 @@ it('forces machine and cleaner relays off when a self-serve wash session is comp
|
|||||||
|
|
||||||
expect($washFlow)->not->toBeFalse();
|
expect($washFlow)->not->toBeFalse();
|
||||||
expect($washFlow)->toContain('$this->disableMachineRelayForCompletedWash($laneId);');
|
expect($washFlow)->toContain('$this->disableMachineRelayForCompletedWash($laneId);');
|
||||||
expect($washFlow)->toContain('bool $disableRelays = true');
|
|
||||||
expect($washFlow)->toContain('if ($disableRelays) {');
|
|
||||||
|
|
||||||
$methodOffset = strpos($washFlow, 'protected function disableMachineRelayForCompletedWash');
|
$methodOffset = strpos($washFlow, 'protected function disableMachineRelayForCompletedWash');
|
||||||
expect($methodOffset)->not->toBeFalse();
|
expect($methodOffset)->not->toBeFalse();
|
||||||
@@ -110,22 +108,3 @@ it('always dispatches completion relay off for configured machine relays without
|
|||||||
[selfserve_lane_relay::MACHINE_CLEANER, false],
|
[selfserve_lane_relay::MACHINE_CLEANER, false],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normal STOP completion skips duplicate completion relay cleanup after STOP already disabled relays', function (): void {
|
|
||||||
$commandTrait = file_get_contents(app_path('modules/selfserve/traits/selfserve_lane_command_t.php'));
|
|
||||||
|
|
||||||
expect($commandTrait)->not->toBeFalse();
|
|
||||||
$methodOffset = strpos($commandTrait, 'protected function completeLatestSessionForStop(): void');
|
|
||||||
expect($methodOffset)->not->toBeFalse();
|
|
||||||
|
|
||||||
$methodBody = substr($commandTrait, (int)$methodOffset, 1500);
|
|
||||||
expect($methodBody)->toContain(<<<'PHP'
|
|
||||||
(new \modules\selfserve\classes\selfserve_wash_flow())->completeLatestSessionForLane(
|
|
||||||
$this->id,
|
|
||||||
$this->getLicensePlate() ?: null,
|
|
||||||
$this->getCustomerNumber() ?: null,
|
|
||||||
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
PHP);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -14,17 +14,6 @@ it('enforces a global 2 second Shelly gate in sendPostRequest', function (): voi
|
|||||||
expect($sendPostRequestBody)->toContain('$this->waitForShellyRateLimitWindow();');
|
expect($sendPostRequestBody)->toContain('$this->waitForShellyRateLimitWindow();');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('bounds Shelly cloud HTTP requests with curl timeouts', function (): void {
|
|
||||||
$shellyClass = file_get_contents(app_path('classes/shelly.php'));
|
|
||||||
|
|
||||||
expect($shellyClass)->not->toBeFalse();
|
|
||||||
expect($shellyClass)->toContain('private const SHELLY_CONNECT_TIMEOUT_SECONDS = 2;');
|
|
||||||
expect($shellyClass)->toContain('private const SHELLY_REQUEST_TIMEOUT_SECONDS = 5;');
|
|
||||||
expect($shellyClass)->toContain('CURLOPT_CONNECTTIMEOUT, self::SHELLY_CONNECT_TIMEOUT_SECONDS');
|
|
||||||
expect($shellyClass)->toContain('CURLOPT_TIMEOUT, self::SHELLY_REQUEST_TIMEOUT_SECONDS');
|
|
||||||
expect($shellyClass)->toContain('CURLOPT_NOSIGNAL, true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses Redis NX PX semantics for cross-request Shelly rate limiting', function (): void {
|
it('uses Redis NX PX semantics for cross-request Shelly rate limiting', function (): void {
|
||||||
$shellyClass = file_get_contents(app_path('classes/shelly.php'));
|
$shellyClass = file_get_contents(app_path('classes/shelly.php'));
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
it('registers Slack module config endpoints and customer registration webhook config', function (): void {
|
|
||||||
$routeFile = app_path('routes/moduleConfigRoute.php');
|
|
||||||
$routeContent = file_get_contents($routeFile);
|
|
||||||
|
|
||||||
expect($routeContent)->not->toBeFalse()
|
|
||||||
->and($routeContent)->toContain('/slack/config')
|
|
||||||
->and($routeContent)->toContain('/slack/config/test')
|
|
||||||
->and($routeContent)->toContain("requirePermission('slack_config')")
|
|
||||||
->and($routeContent)->toContain("(new slack())->getConfig()->getConfigRequest()")
|
|
||||||
->and($routeContent)->toContain("(new slack())->getConfig()->postConfigRequest()")
|
|
||||||
->and($routeContent)->toContain("(new slack())->test_customer_registration_webhook()");
|
|
||||||
|
|
||||||
$moduleContent = file_get_contents(app_path('modules/slack/slack_c.php'));
|
|
||||||
$variableContent = file_get_contents(app_path('modules/slack/config/slack_customer_registration_webhook_url_c.php'));
|
|
||||||
$slackClassContent = file_get_contents(app_path('classes/slack.php'));
|
|
||||||
$authRouteContent = file_get_contents(app_path('routes/authRoute.php'));
|
|
||||||
$openApiContent = file_get_contents(app_path('openapi.yaml'));
|
|
||||||
|
|
||||||
expect($moduleContent)->not->toBeFalse()
|
|
||||||
->and($moduleContent)->toContain("setupConfig('Slack')")
|
|
||||||
->and($moduleContent)->toContain('slack_customer_registration_webhook_url_c::class')
|
|
||||||
->and($variableContent)->not->toBeFalse()
|
|
||||||
->and($variableContent)->toContain("'customer_registration_webhook_url'")
|
|
||||||
->and($variableContent)->toContain('Slack webhook URL used for successful customer registration notifications')
|
|
||||||
->and($slackClassContent)->not->toBeFalse()
|
|
||||||
->and($slackClassContent)->toContain('send_customer_registration_notification')
|
|
||||||
->and($slackClassContent)->toContain('test_customer_registration_webhook')
|
|
||||||
->and($slackClassContent)->toContain('format_customer_registration_test')
|
|
||||||
->and($slackClassContent)->toContain('format_customer_registration')
|
|
||||||
->and($authRouteContent)->not->toBeFalse()
|
|
||||||
->and($authRouteContent)->toContain("AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED")
|
|
||||||
->and($openApiContent)->not->toBeFalse()
|
|
||||||
->and($openApiContent)->toContain('/slack/config')
|
|
||||||
->and($openApiContent)->toContain('/slack/config/test')
|
|
||||||
->and($openApiContent)->toContain('SlackConfigListResponse')
|
|
||||||
->and($openApiContent)->toContain('SlackConfigTestResponse')
|
|
||||||
->and($openApiContent)->toContain('SlackConfigEntry');
|
|
||||||
});
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
app_require('classes/slack.php');
|
|
||||||
|
|
||||||
use classes\slack;
|
|
||||||
|
|
||||||
final class SlackCustomerRegistrationWebhookFake extends slack
|
|
||||||
{
|
|
||||||
public array $messages = [];
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly string $webhook,
|
|
||||||
private readonly string $sendResult = 'Message sent successfully. Response: ok'
|
|
||||||
) {
|
|
||||||
// Skip parent config loading for unit isolation.
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function get_customer_registration_webhook_url(): string
|
|
||||||
{
|
|
||||||
return $this->webhook;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function send_webhook_message(string $message, string $webhook): string
|
|
||||||
{
|
|
||||||
$this->messages[] = [
|
|
||||||
'message' => $message,
|
|
||||||
'webhook' => $webhook,
|
|
||||||
];
|
|
||||||
|
|
||||||
return $this->sendResult;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('does not send customer registration test notifications without a saved webhook', function (): void {
|
|
||||||
$slack = new SlackCustomerRegistrationWebhookFake('');
|
|
||||||
|
|
||||||
$result = $slack->test_customer_registration_webhook();
|
|
||||||
|
|
||||||
expect($result)
|
|
||||||
->toBe([
|
|
||||||
'configured' => false,
|
|
||||||
'sent' => false,
|
|
||||||
'message' => 'Slack customer registration webhook URL is not configured.',
|
|
||||||
])
|
|
||||||
->and($slack->messages)->toBe([])
|
|
||||||
->and($slack->get_log())->toBe([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends customer registration test notifications to the saved webhook', function (): void {
|
|
||||||
$slack = new SlackCustomerRegistrationWebhookFake('https://hooks.slack.test/services/secret-token');
|
|
||||||
|
|
||||||
$result = $slack->test_customer_registration_webhook();
|
|
||||||
|
|
||||||
expect($result)
|
|
||||||
->toBe([
|
|
||||||
'configured' => true,
|
|
||||||
'sent' => true,
|
|
||||||
'message' => 'Slack test message sent successfully.',
|
|
||||||
])
|
|
||||||
->and($slack->messages)->toHaveCount(1)
|
|
||||||
->and($slack->messages[0]['webhook'])->toBe('https://hooks.slack.test/services/secret-token')
|
|
||||||
->and($slack->messages[0]['message'])->toContain('Truck Wash Slack test')
|
|
||||||
->and($slack->messages[0]['message'])->toContain('Customer registration notifications are configured correctly.')
|
|
||||||
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->toContain('sent successfully')
|
|
||||||
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->not->toContain('secret-token');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports customer registration test notification failures without exposing the webhook', function (): void {
|
|
||||||
$slack = new SlackCustomerRegistrationWebhookFake(
|
|
||||||
'https://hooks.slack.test/services/secret-token',
|
|
||||||
'Failed to send message: cURL error for https://hooks.slack.test/services/secret-token'
|
|
||||||
);
|
|
||||||
|
|
||||||
$result = $slack->test_customer_registration_webhook();
|
|
||||||
|
|
||||||
expect($result)
|
|
||||||
->toBe([
|
|
||||||
'configured' => true,
|
|
||||||
'sent' => false,
|
|
||||||
'message' => 'Slack test message failed.',
|
|
||||||
])
|
|
||||||
->and($slack->messages)->toHaveCount(1)
|
|
||||||
->and(json_encode($result, JSON_UNESCAPED_SLASHES))->not->toContain('secret-token')
|
|
||||||
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->toContain('failed')
|
|
||||||
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->not->toContain('secret-token');
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user