Remove outdated edge gateway object classes, add new agent implementation

Transitioned from obsolete gateway object classes (`edge_gateway_shell_action_jobs_o`, `edge_gateway_shell_events_o`, `edge_gateway_shell_sessions_o`, `edge_gateway_update_jobs_o`) to the new agent implementation (`edge-gateway-agent/agent.php`).
This commit is contained in:
Jeppe Bundgaard
2026-04-21 14:13:17 +02:00
parent d30a006457
commit 7d450e285e
90 changed files with 11811 additions and 2760 deletions
+223
View File
@@ -0,0 +1,223 @@
{
"version": 1,
"snapshot_of": "backend-php",
"projects": {
"backend-php": {
"display_name": "Copenhagen Truck Wash API",
"relative_root": ".",
"commands": {
"setup": {
"default": "./scripts/setup.sh",
"win32": "powershell -ExecutionPolicy Bypass -File .\\scripts\\setup.ps1"
},
"run": {
"default": "docker compose up -d traefik redis mysql-debug php1 caddy"
},
"test_unit": {
"default": "docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:unit\""
},
"test_e2e": {
"default": ""
},
"test_full": {
"default": ""
},
"debug": {
"default": "docker compose logs -f --tail=200 php1"
}
},
"codex_environment": {
"name": "api",
"actions": [
{
"name": "Start API",
"icon": "run",
"command_id": "run"
},
{
"name": "Stop API",
"icon": "run",
"literal_command": {
"default": "docker compose down"
}
},
{
"name": "PHP logs",
"icon": "debug",
"command_id": "debug"
},
{
"name": "PHP unit tests",
"icon": "test",
"command_id": "test_unit"
},
{
"name": "AI workflow check",
"icon": "debug",
"literal_command": {
"default": "node scripts/sync-ai-workflow.mjs --check"
}
}
]
},
"generated_content": {
"aiassistant_tests_lines": [
"# Backend PHP Testing Rules",
"",
"These rules apply to `services/nginx/app/tests` and any backend change that needs verification.",
"",
"1. Add or update tests for every new feature, bug fix, API contract change, or search or authentication workflow change.",
"2. Run backend verification in the `php1` container. Do not use host-side PHP for the supported workflow.",
"3. Use `docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:unit\"` for unit coverage.",
"4. Use `docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:integration\"` when code depends on Redis, MySQL, or environment-backed configuration.",
"5. Use `docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:api\"` when route behavior, envelopes, or request parsing changes.",
"6. Keep tests deterministic: no live third-party calls, no shared Redis keys, no broad database cleanup, and no sleeps unless time behavior is the thing under test.",
"7. Prefer narrow fixtures, explicit cleanup, and behavior-level assertions over implementation checks.",
"8. When public routes, schemas, or permissions change, update `openapi.yaml` together with the tests.",
"9. Run the narrowest relevant suite first, then the broader suite that matches the risk before you finish the task.",
"",
"Canonical workflow reference: `.ai-workflow/workflow.md`."
],
"aiassistant_routes_lines": [
"# Backend Route Rules",
"",
"These rules apply to files under `services/nginx/app/routes` and the classes they call.",
"",
"1. Keep route handlers thin: validate input, enforce permissions, call domain code, and write the response.",
"2. Default to protected endpoints. Use the existing authentication and permission helpers instead of ad hoc access checks.",
"3. Add or update backend tests in the `php1` container whenever route behavior changes.",
"4. Update `openapi.yaml` whenever paths, parameters, request bodies, response envelopes, or permissions change.",
"5. Prefer deterministic route tests and avoid live external integrations in route coverage.",
"6. Use concise API errors and keep sensitive implementation details out of the response body.",
"7. When a change touches department, order, or subuser authorization, cover both the allow path and the deny path.",
"",
"Canonical workflow reference: `.ai-workflow/workflow.md`."
],
"junie_lines": [
"# Copenhagen Truck Wash API Development Guidelines",
"",
"This file is generated from the canonical AI workflow and is the supported Junie-facing reference for the backend repository.",
"",
"## Build And Run",
"",
"- Project root: `services/nginx/app` is the effective PHP application root.",
"- Setup: use `./scripts/setup.sh` on POSIX or `powershell -ExecutionPolicy Bypass -File .\\scripts\\setup.ps1` on Windows.",
"- Start local API stack: `docker compose up -d traefik redis mysql-debug php1 caddy`.",
"- Tail logs with `docker compose logs -f --tail=200 php1`.",
"",
"## Testing",
"",
"- Supported backend validation runs in `php1`.",
"- Unit tests: `docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:unit\"`.",
"- Integration tests: `docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:integration\"`.",
"- API tests: `docker compose exec -T php1 sh -lc \"cd /var/www/html && composer test:api\"`.",
"- Prefer the narrowest suite that proves the change, then run the broader suite that matches the risk.",
"",
"## Workflow Notes",
"",
"- Generated assistant metadata is checked with `node scripts/sync-ai-workflow.mjs --check`.",
"- Route and schema changes require matching updates to `openapi.yaml`.",
"- Runtime OpenAI product behavior is out of scope for this workflow bundle unless a task explicitly changes product code.",
"",
"Canonical workflow reference: `.ai-workflow/workflow.md`."
]
}
}
},
"assistants": {
"codex": {
"description": "Codex environment files and actions."
},
"aiassistant": {
"description": "Project-specific AI Assistant rules."
},
"junie": {
"description": "Project-specific Junie guidance."
},
"copilot": {
"description": "Standardized Copilot task dispatch workflow."
}
},
"commands": {
"setup": {
"description": "Install dependencies and prepare the supported local environment."
},
"run": {
"description": "Start the primary local development entrypoint for the project."
},
"test_unit": {
"description": "Run the project's narrow unit-style verification command."
},
"test_e2e": {
"description": "Run the project's targeted browser or end-to-end verification command."
},
"test_full": {
"description": "Run the broader high-confidence verification command when the project defines one."
},
"debug": {
"description": "Run the supported debug entrypoint."
}
},
"generated_outputs": [
{
"id": "backend_codex_environment",
"template": "codex_project_environment",
"project": "backend-php",
"path": ".codex/environments/environment.toml"
},
{
"id": "backend_aiassistant_tests",
"template": "aiassistant_backend_tests_rule",
"project": "backend-php",
"path": ".aiassistant/rules/Creating and maintaining tests.md"
},
{
"id": "backend_aiassistant_routes",
"template": "aiassistant_backend_routes_rule",
"project": "backend-php",
"path": ".aiassistant/rules/Creating and securing routes.md"
},
{
"id": "backend_junie_guidelines",
"template": "junie_backend_guidelines",
"project": "backend-php",
"path": ".junie/guidelines.md"
},
{
"id": "backend_copilot_workflow",
"template": "copilot_dispatcher_workflow",
"project": "backend-php",
"path": ".github/workflows/copilot.yml"
}
],
"sync_targets": {
"backend-php": {
"source_root": ".",
"destination": "C:\\Users\\2jepp\\PhpstormProjects\\api",
"supported_metadata_dirs": [
".codex",
".aiassistant",
".junie",
".github",
".ai-workflow",
"scripts"
]
}
},
"copilot": {
"workflow_name": "Copilot Task Dispatcher",
"input_description": "The task description for Copilot",
"issue_label": "copilot-task",
"title_prefix": "Copilot Task:",
"body_lines": [
"Assigned to Copilot by @{{ACTOR}}.",
"",
"Task",
"{{TASK}}",
"",
"AI workflow notes",
"- Generated assistant metadata is synchronized from the canonical AI workflow bundle.",
"- Run `node scripts/sync-ai-workflow.mjs --check` if assistant metadata changed."
]
}
}
+74
View File
@@ -0,0 +1,74 @@
<!-- AUTOGENERATED SNAPSHOT for backend-php: refresh from the canonical workspace with `node scripts/sync-ai-workflow.mjs --write`. -->
# Developer AI Workflow
This directory is the canonical source of truth for the repository's developer-facing AI workflow.
## Goals
- Keep Codex, AI Assistant, Junie, and Copilot aligned from one maintained source.
- Make assistant metadata deterministic so the generated files can be rewritten safely and checked in CI.
- Preserve the current mirror-repo workflow for `backend-php` and `front-end-vue`.
- Keep all changes in this workflow scoped to developer tooling and documentation. Runtime OpenAI features stay out of scope.
## Supported Assistants
- `Codex`: local environments and actions under `.codex/environments`.
- `AI Assistant`: generated guidance under `.aiassistant/rules`.
- `Junie`: generated project guidance under `.junie/guidelines.md`.
- `Copilot`: standardized issue-dispatch workflow content under `.github/workflows/copilot.yml`.
## Global Rules
1. Change only `.ai-workflow/workflow.md` and `.ai-workflow/manifest.json` when updating the developer AI workflow.
2. Regenerate all derived files with `node scripts/sync-ai-workflow.mjs --write`.
3. Validate drift with `node scripts/sync-ai-workflow.mjs --check`.
4. Generated assistant files are not hand-edited.
5. Unsupported surfaces stay unsupported until they have a real owner and a real config.
6. `front-end-vue/.ai/mcp/mcp.json` is intentionally unsupported and should not be recreated until there is an actual MCP integration to maintain.
## Command Matrix
### `backend-php`
- Setup: use the existing setup scripts in `backend-php/scripts`.
- Run: start the API stack with `traefik`, `redis`, `mysql-debug`, `php1`, and `caddy`.
- Debug: tail `php1` logs.
- PHP verification: always run backend validation in the `php1` container.
- Unit tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:unit"`.
- Integration tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:integration"`.
- API tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:api"`.
### `front-end-vue`
- Setup: `npm ci` and `npx playwright install`.
- Run: `npm run dev`.
- Unit tests: `npm run test:unit` with Vitest.
- Browser smoke: `npm run test:e2e:smoke`.
- Browser full suite: `npm run test:e2e:ci`.
- Debug: `npx playwright test --debug`.
- Browser validation is Playwright-first. WebdriverIO and Appium are not part of the supported workflow.
### `automation`
- Setup: `npm ci` and `npx playwright install`.
- Run: `npx playwright test --ui`.
- Browser tests: `npx playwright test`.
- Debug: `npx playwright test --debug`.
## Mirror Repos And Sync
- `backend-php` mirrors to `C:\Users\2jepp\PhpstormProjects\api`.
- `front-end-vue` mirrors to `C:\Users\2jepp\WebstormProjects\pleno-vue`.
- Supported metadata directories that must stay mirrored are `.codex`, `.aiassistant`, `.junie`, `.github`, generated `.ai-workflow`, and `scripts/sync-ai-workflow.mjs`.
- Cache and build directories remain excluded from the watcher.
- The mirror repos receive generated snapshots of `.ai-workflow` and `scripts/sync-ai-workflow.mjs` so their local CI can run `--check` without depending on the combined workspace root.
## Generated Outputs
- Root Codex environment for combined backend and frontend entrypoints.
- Backend and frontend Codex environments.
- Backend and frontend AI Assistant guidance.
- Backend and frontend Junie guidance.
- Backend Copilot dispatcher workflow.
- Backend and frontend snapshot copies of `.ai-workflow` plus `scripts/sync-ai-workflow.mjs` for mirrored repositories.
@@ -2,261 +2,20 @@
apply: always
---
### Creating and maintaining tests — Rules (Projectspecific)
<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->
These rules apply to all tests under `services/nginx/app/tests` for the Copenhagen Truck Wash API. They codify how to create, run, and maintain the lightweight CLI PHP tests used in this repository.
# Backend PHP Testing Rules
1. Scope and philosophy
- Prefer fast, deterministic CLI scripts over frameworkbased tests.
- Keep unitstyle tests isolated from I/O (no DB/Redis/files/network). Use test doubles and explicit `require_once` for only the code exercised.
- Use integration tests sparingly and only when configuration/globals are required. Run them in Docker with `USE_ENV=true`.
These rules apply to `services/nginx/app/tests` and any backend change that needs verification.
2. Location and naming
- Place tests in `services/nginx/app/tests/<domain>/YourTest.php` where `<domain>` reflects the feature area (e.g., `subusers`, `orders`, `redis`).
- File names must end with `Test.php` (e.g., `SelfservePermissionInitTest.php`).
- Keep one coherent scenario per file. If a file grows beyond ~150 lines or mixes unrelated scenarios, split it.
3. Execution modes
- Unitstyle (preferred): run on host with PHP CLI.
- Command: `php services/nginx/app/tests/<domain>/<Name>Test.php`
- Integrationstyle (needs env/globals): run inside Docker (php1) where `USE_ENV=true` and env vars are provided by `docker-compose.yml`.
- Start minimal stack: `docker compose up -d traefik redis php1 caddy`
- Command: `docker compose exec -T php1 php /var/www/html/tests/<domain>/<Name>Test.php`
4. Bootstrapping and includes
- At the top of every test, define `WD` if not already defined:
```php
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
```
- For unitstyle tests: include only the files you exercise via explicit `require_once WD . '/path/to/file.php';`.
- Do NOT include `config.php` for unitstyle tests; it throws if `USE_ENV` is not set.
- For integrationstyle tests that need globals (`$CONFIG_DB`, `$REDIS_CONFIG`, etc.): `require_once WD . '/config.php';` and run the test inside `php1`.
5. Output and exit codes
- Use simple helpers for humanreadable output:
```php
function ok($message){ echo "\n\033[32m✔ $message\033[0m\n"; }
function fail($message){ echo "\n\033[31m✖ $message\033[0m\n"; }
```
- Prefer collecting failures and exiting nonzero when any assertion fails:
```php
$failed = 0;
// ... on failure set $failed = 1;
exit($failed);
```
- End with a concise completion line, e.g., `echo "\n<Name>Test completed.\n";`.
6. Determinism, time, and randomness
- Avoid `sleep()` and real time dependencies. If time is relevant, pass it as a parameter or stub the time source.
- Do not use random values unless strictly necessary; if used, seed explicitly (`mt_srand(1234)`) and document it.
7. External I/O and side effects
- Never call external services (Stripe, economic, MinIO, Slack, WordPress) from tests. Stub or override code paths as shown in `tests/subusers/SelfservePermissionInitTest.php`.
- Do not modify files under the repository during tests. If temporary files are required, use `sys_get_temp_dir()` and delete them before exit.
8. Redis and database (integrationonly)
- Run inside Docker (`php1`) so `config.php` can populate `$REDIS_CONFIG` and `$CONFIG_DB`.
- Use unique, namespaced Redis keys for test data (e.g., `test:<feature>:<uuid>`). Clean them up at the end. Do NOT call `FLUSHALL`.
- Prefer fakes/stubs over real DB writes. If DB writes are unavoidable, scope them to clearly identifiable rows and delete them before exit.
9. Performance budgets
- Each unitstyle test script should complete in < 100 ms on a typical dev machine.
- Each integrationstyle test should complete in < 2 s and must avoid N+1 loops or heavy queries.
10. When to add or update tests
- New route/feature: add at least one unitstyle test for core logic. If behavior depends on configuration/globals, add a minimal integration test.
- Bug fix: first reproduce with a failing test; then fix and ensure the test passes.
- Refactor: keep behaviorpreserving tests green. If public behavior changes intentionally, update both tests and `openapi.yaml` accordingly.
11. Maintenance expectations
- Do not weaken or remove assertions to “make tests pass”. Investigate and fix root causes.
- Keep tests small and readable. Extract tiny helpers within the test file rather than introducing new shared libraries.
- Match the project code style and line endings (see `.editorconfig`: UTF8, CRLF, 4space indent, class brace `next_line`).
12. Templates
- Unitstyle template:
```php
<?php
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
// require_once WD . '/modules/foo/classes/bar.php';
function ok($m){ echo "\n\033[32m✔ $m\033[0m\n"; }
function fail($m){ echo "\n\033[31m✖ $m\033[0m\n"; }
$failed = 0;
// Arrange / Act
$sum = 2 + 2;
// Assert
if ($sum === 4) { ok('Basic arithmetic works (2 + 2 = 4)'); } else { fail('Expected 4'); $failed = 1; }
echo "\nExampleUnitTest completed.\n";
exit($failed);
```
- Integrationstyle template (Redis example):
```php
<?php
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
require_once WD . '/config.php'; // populates $REDIS_CONFIG when USE_ENV=true
require_once WD . '/classes/redis.php';
function ok($m){ echo "\n\033[32m✔ $m\033[0m\n"; }
function fail($m){ echo "\n\033[31m✖ $m\033[0m\n"; }
$failed = 0;
// Use a namespaced test key
$key = 'test:redis:example:' . uniqid('', true);
try {
$r = new classes\redis();
$r->set($key, 'pong');
$val = $r->get($key);
if ($val === 'pong') { ok('Redis set/get works for namespaced key'); } else { fail('Unexpected value from Redis'); $failed = 1; }
} finally {
// Besteffort cleanup
try { $r->del($key); } catch (\Throwable $e) { /* ignore */ }
}
echo "\nExampleRedisIntegrationTest completed.\n";
exit($failed);
```
13. Quick reference
- Run unitstyle test on host: `php services/nginx/app/tests/<domain>/<Name>Test.php`
- Run integrationstyle test in Docker: `docker compose exec -T php1 php /var/www/html/tests/<domain>/<Name>Test.php`
- Minimal stack for integration: `docker compose up -d traefik redis php1 caddy`
For broader build, configuration, and existing examples, see `.junie/guidelines.md` (Testing section).
---
apply: always
---
### Creating and maintaining tests — Rules (Projectspecific)
These rules apply to all tests under `services/nginx/app/tests` for the Copenhagen Truck Wash API. They codify how to create, run, and maintain the lightweight CLI PHP tests used in this repository.
1. Scope and philosophy
- Prefer fast, deterministic CLI scripts over frameworkbased tests.
- Keep unitstyle tests isolated from I/O (no DB/Redis/files/network). Use test doubles and explicit `require_once` for only the code exercised.
- Use integration tests sparingly and only when configuration/globals are required. Run them in Docker with `USE_ENV=true`.
2. Location and naming
- Place tests in `services/nginx/app/tests/<domain>/YourTest.php` where `<domain>` reflects the feature area (e.g., `subusers`, `orders`, `redis`).
- File names must end with `Test.php` (e.g., `SelfservePermissionInitTest.php`).
- Keep one coherent scenario per file. If a file grows beyond ~150 lines or mixes unrelated scenarios, split it.
3. Execution modes
- Unitstyle (preferred): run on host with PHP CLI.
- Command: `php services/nginx/app/tests/<domain>/<Name>Test.php`
- Integrationstyle (needs env/globals): run inside Docker (php1) where `USE_ENV=true` and env vars are provided by `docker-compose.yml`.
- Start minimal stack: `docker compose up -d traefik redis php1 caddy`
- Command: `docker compose exec -T php1 php /var/www/html/tests/<domain>/<Name>Test.php`
4. Bootstrapping and includes
- At the top of every test, define `WD` if not already defined:
```php
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
```
- For unitstyle tests: include only the files you exercise via explicit `require_once WD . '/path/to/file.php';`.
- Do NOT include `config.php` for unitstyle tests; it throws if `USE_ENV` is not set.
- For integrationstyle tests that need globals (`$CONFIG_DB`, `$REDIS_CONFIG`, etc.): `require_once WD . '/config.php';` and run the test inside `php1`.
5. Output and exit codes
- Use simple helpers for humanreadable output:
```php
function ok($message){ echo "\n\033[32m✔ $message\033[0m\n"; }
function fail($message){ echo "\n\033[31m✖ $message\033[0m\n"; }
```
- Prefer collecting failures and exiting nonzero when any assertion fails:
```php
$failed = 0;
// ... on failure set $failed = 1;
exit($failed);
```
- End with a concise completion line, e.g., `echo "\n<Name>Test completed.\n";`.
6. Determinism, time, and randomness
- Avoid `sleep()` and real time dependencies. If time is relevant, pass it as a parameter or stub the time source.
- Do not use random values unless strictly necessary; if used, seed explicitly (`mt_srand(1234)`) and document it.
7. External I/O and side effects
- Never call external services (Stripe, economic, MinIO, Slack, WordPress) from tests. Stub or override code paths as shown in `tests/subusers/SelfservePermissionInitTest.php`.
- Do not modify files under the repository during tests. If temporary files are required, use `sys_get_temp_dir()` and delete them before exit.
8. Redis and database (integrationonly)
- Run inside Docker (`php1`) so `config.php` can populate `$REDIS_CONFIG` and `$CONFIG_DB`.
- Use unique, namespaced Redis keys for test data (e.g., `test:<feature>:<uuid>`). Clean them up at the end. Do NOT call `FLUSHALL`.
- Prefer fakes/stubs over real DB writes. If DB writes are unavoidable, scope them to clearly identifiable rows and delete them before exit.
9. Performance budgets
- Each unitstyle test script should complete in < 100 ms on a typical dev machine.
- Each integrationstyle test should complete in < 2 s and must avoid N+1 loops or heavy queries.
10. When to add or update tests
- New route/feature: add at least one unitstyle test for core logic. If behavior depends on configuration/globals, add a minimal integration test.
- Bug fix: first reproduce with a failing test; then fix and ensure the test passes.
- Refactor: keep behaviorpreserving tests green. If public behavior changes intentionally, update both tests and `openapi.yaml` accordingly.
11. Maintenance expectations
- Do not weaken or remove assertions to “make tests pass”. Investigate and fix root causes.
- Keep tests small and readable. Extract tiny helpers within the test file rather than introducing new shared libraries.
- Match the project code style and line endings (see `.editorconfig`: UTF8, CRLF, 4space indent, class brace `next_line`).
12. Templates
- Unitstyle template:
```php
<?php
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
// require_once WD . '/modules/foo/classes/bar.php';
function ok($m){ echo "\n\033[32m✔ $m\033[0m\n"; }
function fail($m){ echo "\n\033[31m✖ $m\033[0m\n"; }
$failed = 0;
// Arrange / Act
$sum = 2 + 2;
// Assert
if ($sum === 4) { ok('Basic arithmetic works (2 + 2 = 4)'); } else { fail('Expected 4'); $failed = 1; }
echo "\nExampleUnitTest completed.\n";
exit($failed);
```
- Integrationstyle template (Redis example):
```php
<?php
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
require_once WD . '/config.php'; // populates $REDIS_CONFIG when USE_ENV=true
require_once WD . '/classes/redis.php';
function ok($m){ echo "\n\033[32m✔ $m\033[0m\n"; }
function fail($m){ echo "\n\033[31m✖ $m\033[0m\n"; }
$failed = 0;
// Use a namespaced test key
$key = 'test:redis:example:' . uniqid('', true);
try {
$r = new classes\redis();
$r->set($key, 'pong');
$val = $r->get($key);
if ($val === 'pong') { ok('Redis set/get works for namespaced key'); } else { fail('Unexpected value from Redis'); $failed = 1; }
} finally {
// Besteffort cleanup
try { $r->del($key); } catch (\Throwable $e) { /* ignore */ }
}
echo "\nExampleRedisIntegrationTest completed.\n";
exit($failed);
```
13. Quick reference
- Run unitstyle test on host: `php services/nginx/app/tests/<domain>/<Name>Test.php`
- Run integrationstyle test in Docker: `docker compose exec -T php1 php /var/www/html/tests/<domain>/<Name>Test.php`
- Minimal stack for integration: `docker compose up -d traefik redis php1 caddy`
For broader build, configuration, and existing examples, see `.junie/guidelines.md` (Testing section).
1. Add or update tests for every new feature, bug fix, API contract change, or search or authentication workflow change.
2. Run backend verification in the `php1` container. Do not use host-side PHP for the supported workflow.
3. Use `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:unit"` for unit coverage.
4. Use `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:integration"` when code depends on Redis, MySQL, or environment-backed configuration.
5. Use `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:api"` when route behavior, envelopes, or request parsing changes.
6. Keep tests deterministic: no live third-party calls, no shared Redis keys, no broad database cleanup, and no sleeps unless time behavior is the thing under test.
7. Prefer narrow fixtures, explicit cleanup, and behavior-level assertions over implementation checks.
8. When public routes, schemas, or permissions change, update `openapi.yaml` together with the tests.
9. Run the narrowest relevant suite first, then the broader suite that matches the risk before you finish the task.
Canonical workflow reference: `.ai-workflow/workflow.md`.
@@ -2,172 +2,18 @@
apply: always
---
### Creating and securing routes — Rules (Projectspecific)
<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->
These rules define how to add HTTP routes to the Copenhagen Truck Wash API and how to secure them consistently. They reflect current patterns in `services/nginx/app/routes` and the helper APIs in `traits\route_t`.
# Backend Route Rules
1. Location, naming, and structure
- Place route classes under `services/nginx/app/routes`.
- File names should describe the domain and end with `Route.php`, e.g., `BookingsRoute.php`, `ModuleSelfServeRoute.php`.
- Namespace must be `routes` and each file must define one class that uses `traits\route_t` and implements `run(): void`.
```php
<?php
namespace routes;
use traits\route_t;
These rules apply to files under `services/nginx/app/routes` and the classes they call.
class ExampleRoute
{
use route_t;
public function run(): void
{
// endpoints go here
}
}
```
2. Registering endpoints (HTTP verbs)
- Define endpoints inside `run()` using the helpers provided by `route_t`:
- `get($path, $handler, $permissions = [])`
- `post($path, $handler, $permissions = [])`
- `put($path, $handler, $permissions = [])`
- `delete($path, $handler, $permissions = [])`
- `patch($path, $handler, $permissions = [])`
- `options($path, $handler, $permissions = [])` (useful for CORS preflight when needed)
- Handlers are closures that perform validation, authorization, side effects, and write responses via the global `$response` (`classes\response`).
- Example (readonly endpoint):
```php
$this->get('/example', function () {
global $response;
$response->success(['message' => 'Hello World!']);
});
```
3. Route paths and local routing
- Caddy serves the app directly; Traefik adds an external `/api` prefix for local access.
- Direct path in the app: `/foo/bar`.
- Local access paths (both work):
- `http://localhost/foo/bar` (direct)
- `http://localhost/api/foo/bar` (Traefik stripprefix `/api` → still routes to `/foo/bar` in the app)
- Keep paths meaningful and resourceoriented. Prefer plural nouns for collections and subpaths for actions when unavoidable (e.g., `/modules/self-serve/lane/status`).
4. Responses and status codes
- Use `$response->success($payload, $status = 200)` for successful results and `$response->error($messageOrPayload, $status)` for failures.
- Typical statuses:
- 200 OK for successful reads/updates; 201 Created when new resources are created.
- 400 Bad Request for validation errors.
- 401 Unauthorized when authentication is missing/invalid.
- 403 Forbidden when authenticated but lacking permissions.
- Return concise, nonsensitive error messages. Internal details go to logs (see `objects\logs_o`).
5. Input handling and validation (route_t helpers)
- Read parameters via:
- `getParameter($name)` or `getParametersAsArray()` (preferred; integrates with `classes\response` parsing)
- `fromRequest($name)` (checks JSON body, then `$_POST`, then query)
- `fromQuery($name)` and `fromRoute($segmentName)` when relevant
- Validate early and fail fast using helpers:
- Presence: `requireParameters(['a','b'])`, `isParametersSet(['a','b'])`
- Types: `requireType($val, type_string()|type_int()|TYPE_BOOL()|TYPE_ARRAY())`, `requireTypeIn($val, [...])`
- Ranges/lengths: `requireMinValue($n, $min)`, `requireMaxValue($n, $max)`, `requireMaxLength($name, $len)`, `requireParameterIntPositive($n, $name)`
- Formats/enums: `requireDateFormat($date, FORMAT_DATE())`, `requireInArray($val, $allowed)`
6. Authentication and authorization (core rules)
- Default posture: endpoints are protected and require authentication unless explicitly public.
- To check authentication without a specific permission: `isAuthenticated()`.
- Enforce permissions inside handlers using:
- `requirePermission('some_permission')` — throws 401/403 as appropriate.
- `hasPermission('some_permission', $customerNumber = null)` — boolean check (do not throw).
- Register permissions for discoverability/administration by supplying the third `$permissions` argument when declaring the route. Keys are permission names; values are humanreadable descriptions. You should still call `requirePermission()` inside the handler to actually enforce.
```php
$this->get('/modules/self-serve/lane/status', function () {
global $response;
self::requirePermission('modules_selfserve_lane_status_view');
// ...
$response->success(['status' => 'OK']);
}, [
'modules_selfserve_lane_status_view' => 'View self-serve lane status',
]);
```
7. Subusers, permission nodes, and customer context
- When a permission corresponds to a subuser permission node, define it with `definePermission($perm, subusers_permission_node_key::CASE)` to keep mapping explicit.
- Subuser contexts may target a specific customer via the `X-Customer-Number` header or a `customer_number` parameter; `route_t` exposes this as response meta (`target_customer_number`) when applicable.
- Use `allowOwnOrDepartmentAccess($ownPerm, $deptPerm, $targetCustomerNumber, $departmentId, $ownGuard = null, $denyMessage = null)` for the common pattern: allow the principal to act on their own customer scope or fall back to a department/admin permission.
- Use `isOwnCustomerContext($targetCustomerNumber)` and `resolveEffectiveCustomerNumber()` when building conditional logic.
8. Department, order, and special access helpers
- Department access: `requireDepartmentAccess($departmentId, $permissionSuffix = null)`, `hasDepartmentAccess(...)`.
- Order access: `requireOrderAccess($orderId, $permissionSuffix = null)` (note: indirect access rules may evolve; keep logic minimal in routes).
- Plate scanners (API key based): protect endpoints with `requirePlateScannerAuth()`.
- Bot protection (public forms): use `requireRecaptcha()` and expect `g_recaptcha_response` in the request.
9. Public endpoints
- Keep public endpoints extremely limited. If an endpoint must be public, you must:
- Validate all inputs thoroughly using the helpers above.
- Add ratelimit or abusemitigation where relevant (Redis is available as `classes\redis` for counters/locks; coordinate design with maintainers before introducing new limits).
- Prefer `requireRecaptcha()` for anonymous form submissions.
10. Side effects and idempotency
- For POST/PUT/PATCH with side effects, make operations idempotent when feasible (e.g., by honoring a client idempotency key header). If you add such behavior, document it in the routes docblock and in `openapi.yaml`.
- Wrap multistep operations with proper validation and permission checks before any external calls (Stripe, economic, etc.). Do not leak thirdparty error payloads directly; map to concise API errors and log details.
11. Logging and observability
- On auth/permission denials, the helpers already log to `logs_o` with structured messages. Avoid duplicating logs for the same event.
- For unexpected states you handle gracefully, add targeted logs via `objects\logs_o` with a clear category/key.
12. OpenAPI contract synchronization
- Every public surface change must be reflected in `openapi.yaml` at repo root. Keep paths, methods, parameters, request/response schemas, and error statuses up to date.
- Use the same path strings as in the route declaration (remember Traefiks `/api` is stripped before hitting the app).
- If you add or change permissions that users must hold, document them in the endpoint description.
13. Example: secure command endpoint
```php
<?php
namespace routes;
use traits\route_t;
use classes\authentication;
class LaneCommandRoute
{
use route_t;
public function run(): void
{
$this->post('/modules/self-serve/lane/command', function () {
global $response;
// AuthZ
self::requirePermission('modules_selfserve_lane_command_execute');
// Validate input
self::requireParameters(['lane_id', 'command']);
$laneId = (int) self::getParameter('lane_id');
self::requireType($laneId, self::type_int());
self::requireMinValue($laneId, 1);
$cmd = (string) self::getParameter('command');
self::requireType($cmd, self::type_string());
self::requireInArray($cmd, ['START','STOP','RESERVE','RELEASE','RESET']);
// Business logic (call into modules/classes)
// ...
$response->success(['ok' => true]);
}, [
'modules_selfserve_lane_command_execute' => 'Execute selfserve lane commands',
]);
}
}
```
14. Quick reference (local)
- Bring up minimal stack: `docker compose up -d traefik redis php1 caddy`
- Access an app route locally:
- `http://localhost/<path>` or `http://localhost/api/<path>`
- Tests are CLI scripts under `services/nginx/app/tests`. Prefer unitstyle tests for core logic; see “Creating and maintaining tests — Rules”.
15. Code style and hygiene
- Follow `.editorconfig` (UTF8, CRLF, 4space indents).
- Keep route files cohesive; avoid adding unrelated endpoints to the same class. If a class exceeds ~200300 lines or mixes domains, split it.
- Do not perform heavy bootstrap in route files. Delegate to classes/modules and keep handlers thin: validate → authorize → invoke → respond.
1. Keep route handlers thin: validate input, enforce permissions, call domain code, and write the response.
2. Default to protected endpoints. Use the existing authentication and permission helpers instead of ad hoc access checks.
3. Add or update backend tests in the `php1` container whenever route behavior changes.
4. Update `openapi.yaml` whenever paths, parameters, request bodies, response envelopes, or permissions change.
5. Prefer deterministic route tests and avoid live external integrations in route coverage.
6. Use concise API errors and keep sensitive implementation details out of the response body.
7. When a change touches department, order, or subuser authorization, cover both the allow path and the deny path.
Canonical workflow reference: `.ai-workflow/workflow.md`.
+26 -11
View File
@@ -3,31 +3,46 @@ version = 1
name = "api"
[setup]
script = "./scripts/setup.sh"
script = '''
./scripts/setup.sh
'''
[setup.win32]
script = "powershell -ExecutionPolicy Bypass -File .\\scripts\\setup.ps1"
script = '''
powershell -ExecutionPolicy Bypass -File .\scripts\setup.ps1
'''
[[actions]]
name = "Start API"
icon = "run"
command = "powershell -ExecutionPolicy Bypass -File .\\scripts\\run.ps1 -Action start"
platform = "win32"
command = '''
docker compose up -d traefik redis mysql-debug php1 caddy
'''
[[actions]]
name = "Stop API"
icon = "run"
command = "powershell -ExecutionPolicy Bypass -File .\\scripts\\run.ps1 -Action stop"
platform = "win32"
command = '''
docker compose down
'''
[[actions]]
name = "PHP logs"
icon = "debug"
command = "powershell -ExecutionPolicy Bypass -File .\\scripts\\run.ps1 -Action logs -Service php1"
platform = "win32"
command = '''
docker compose logs -f --tail=200 php1
'''
[[actions]]
name = "Unit tests"
name = "PHP unit tests"
icon = "test"
command = "powershell -ExecutionPolicy Bypass -File .\\scripts\\run.ps1 -Action test"
platform = "win32"
command = '''
docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:unit"
'''
[[actions]]
name = "AI workflow check"
icon = "debug"
command = '''
node scripts/sync-ai-workflow.mjs --check
'''
+13 -2
View File
@@ -14,7 +14,7 @@ jobs:
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Create GitHub Issue for Copilot
env:
@@ -22,7 +22,18 @@ jobs:
TASK: ${{ github.event.inputs.task }}
ACTOR: ${{ github.actor }}
run: |
BODY="$(cat <<EOF
Assigned to Copilot by @$ACTOR.
Task
$TASK
AI workflow notes
- Generated assistant metadata is synchronized from the canonical AI workflow bundle.
- Run `node scripts/sync-ai-workflow.mjs --check` if assistant metadata changed.
EOF
)"
gh issue create \
--title "Copilot Task: $TASK" \
--body "Assigned to Copilot by @$ACTOR. Description: $TASK" \
--body "$BODY" \
--label "copilot-task"
+8
View File
@@ -13,6 +13,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Check AI workflow sync
run: node scripts/sync-ai-workflow.mjs --check
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
+1
View File
@@ -10,3 +10,4 @@
/.idea/
.env
/services/caddy/logs*
/.tmp/
+19 -139
View File
@@ -1,148 +1,28 @@
### Copenhagen Truck Wash API — Development Guidelines (Projectspecific)
<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->
#### Scope
This document captures projectspecific knowledge for building, configuring, testing, and extending the API. It assumes an advanced developer familiar with Docker, PHP 8.2, and HTTP APIs.
# Copenhagen Truck Wash API Development Guidelines
---
This file is generated from the canonical AI workflow and is the supported Junie-facing reference for the backend repository.
### Build and Configuration
## Build And Run
- Stack overview (local):
- Reverse proxy/router: Traefik 2.x (`docker-compose.yml` service `traefik`).
- Web server: Caddy (`caddy`) serving the PHP app from `services/nginx/app` and proxied by Traefik.
- PHP runtime: Multiple PHPFPM containers (`php1`..`php5`), sharing the bindmounted app directory.
- Redis: `redis` for caching/queues/locks.
- Project root: `services/nginx/app` is the effective PHP application root.
- Setup: use `./scripts/setup.sh` on POSIX or `powershell -ExecutionPolicy Bypass -File .\scripts\setup.ps1` on Windows.
- Start local API stack: `docker compose up -d traefik redis mysql-debug php1 caddy`.
- Tail logs with `docker compose logs -f --tail=200 php1`.
- App location: `services/nginx/app` is the effective application root (many scripts/tests derive `WD` to point here).
## Testing
- Composer and dependencies:
- `services/php/Dockerfile` installs Composer and PHP extensions.
- `services/php/docker-entrypoint.sh` performs a guarded Composer install on `php1` at container start when `AUTO_COMPOSER_INSTALL=true` and `composer.json` is present.
- App dependencies live under `services/nginx/app/composer.json` (note: very light, primarily runtime libs; dev tool `rector/rector`).
- Supported backend validation runs in `php1`.
- Unit tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:unit"`.
- Integration tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:integration"`.
- API tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:api"`.
- Prefer the narrowest suite that proves the change, then run the broader suite that matches the risk.
- Configuration source of truth during containerized runs is environment variables consumed by `services/nginx/app/config.php`.
- `config.php` requires `USE_ENV=true`; otherwise it throws an exception. Many CLI scripts/tests bypass `config.php` entirely to remain envagnostic.
## Workflow Notes
- Local run targets and routing:
- Traefik exposes:
- `http://localhost` → routes to Caddy → app (HTTP only for dev).
- `https://localhost` → also mapped, using Traefiks default/selfsigned dev cert.
- `http(s)://localhost/api/*` → Traefik stripprefix middleware forwards to Caddy; the app sees paths without the `/api` prefix.
- For productionlike HTTPS with real certs, see `README.md` for `LETSENCRYPT_PATH` mounting strategy (only needed if you want the exact `api.truckwash.dk` TLS behavior locally).
- Generated assistant metadata is checked with `node scripts/sync-ai-workflow.mjs --check`.
- Route and schema changes require matching updates to `openapi.yaml`.
- Runtime OpenAI product behavior is out of scope for this workflow bundle unless a task explicitly changes product code.
- Minimal bringup for local development:
- Prerequisites: Docker Desktop 4.x+.
- First run will build PHP images and start dependent services. Composer install runs automatically on `php1`.
- Recommended minimal set:
- `docker compose up -d traefik redis php1 caddy`
- Full set (scale out PHP or add observability as needed):
- `docker compose up -d` (starts `traefik`, `redis`, `caddy`, `php1`..`php5`, and other declared services).
- Logs:
- `docker compose logs -f caddy`
- `docker compose logs -f php1`
- `docker compose logs -f traefik`
- Security note: `docker-compose.yml` currently embeds sensitive env values (DB, API tokens). Treat the file as secret in private repos; never republish as is. Prefer `.env` overrides and secrets providers for wider teams.
---
### Testing
The repository does not use PHPUnit for the app. Instead, tests are lightweight CLI scripts under `services/nginx/app/tests`. Conventions:
- Test style
- Selfcontained procedural PHP scripts intended to be executed with `php`. No framework required.
- Many tests define the `WD` constant to the app root and then `require_once` specific class/trait/interface files they exercise.
- Integrationstyle scripts that need configuration will rely on `services/nginx/app/config.php` and so must run within a properly provisioned environment (Docker containers with `USE_ENV=true`).
- Fast unitstyle scripts should avoid `config.php` and any I/O; they manually include only whats needed and/or use test doubles.
- Running tests from host (fastest path)
- Prereq: PHP CLI available on host. Verified with:
- `php -v` → observed on our env: `PHP 8.2.30 (cli)`
- Example: an existing, fully selfcontained test validating subuser permission wiring:
- Command:
- `php services/nginx/app/tests/subusers/SelfservePermissionInitTest.php`
- Verified output (captured):
-
```
✔ SELFSERVE_ADD is granted as expected
✔ SELFSERVE_LIST is not granted as expected
✔ SELFSERVE_EDIT is not granted as expected
✔ SELFSERVEDELETE is not granted as expected
SelfservePermissionInitTest completed.
```
- Running tests inside Docker (when host PHP is unavailable or when env is required)
- Ensure containers are up: `docker compose up -d traefik redis php1 caddy`
- Execute a test within `php1`:
- `docker compose exec -T php1 php /var/www/html/tests/subusers/SelfservePermissionInitTest.php`
- For integration tests that depend on `config.php` (e.g., Redis/DB), environment variables are preconfigured in `docker-compose.yml` for the PHP services. Running inside `php1` will satisfy `USE_ENV=true`.
- Adding a new test
- Place the file under `services/nginx/app/tests/<domain>/YourTest.php`.
- At top of the file, define `WD` if not already defined:
```php
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
```
- Prefer selfcontained tests that avoid I/O. If you need to touch internal classes without Composer autoloading, include files directly, mirroring existing tests.
- If the test must hit Redis/DB or rely on globals from `config.php`, run it inside a PHP container (`php1`) with `USE_ENV=true`.
- Demonstration: creating and running a minimal test
- We created a temporary, envagnostic test at `services/nginx/app/tests/examples/HelloWorldTest.php` with these semantics:
- Define `WD`, perform trivial assertions, and print success markers.
- Command executed and verified output:
- Command:
- `php services/nginx/app/tests/examples/HelloWorldTest.php`
- Output (captured):
-
```
✔ Basic arithmetic works (2 + 2 = 4)
✔ WD points to the application root: C:\\Users\\2jepp\\PhpstormProjects\\api\\services\\nginx\\app
HelloWorldTest completed.
```
- The example file was removed afterwards to keep the repository unchanged. You can replicate by creating a similar file and removing it after execution.
---
### Additional Development Information
- Routing and HTTP surface
- Routes live under `services/nginx/app/routes`. Example route `exampleRoute.php` exposes `GET /example` returning `{"message":"Hello World!"}`. Local access paths (with Traefik):
- `http://localhost/example` (direct)
- `http://localhost/api/example` (Traefik stripprefix `/api` → still routes to `/example` in the app).
- The OpenAPI contract is at the repo root `openapi.yaml` (large, authoritative). Keep it synchronized with implemented routes and payloads.
- Module layout and traits
- Domain modules live in `services/nginx/app/modules/*` and are heavily traitbased. Tests often pull in precise files from here to avoid full app bootstrap.
- Example: subusers module (`modules/subusers/...`) provides permission node containers and helpers. The test `tests/subusers/SelfservePermissionInitTest.php` demonstrates overriding DBbacked methods to inject permissions for fast, deterministic checks.
- Config and globals
- `services/nginx/app/config.php` populates globals like `$CONFIG_DB`, `$REDIS_CONFIG`, etc., but only when `USE_ENV=true`. If you see tests throwing “Environment variables are not set”, run them inside Docker or set required envs for host PHP.
- Code style
- `.editorconfig` at repo root configures formatting. Key PHP rules:
- Encoding: UTF8 with CRLF line endings.
- Indent: 4 spaces; continuation indent 4.
- Class brace style: next_line; function/method blank lines: 1.
- Import sorting: alphabetic; various alignment toggles are disabled.
- Match existing patterns (procedural scripts for tests; namespacing in app code; traits for crosscutting concerns). Avoid adding new frameworks for tests unless explicitly requested.
- Composer / Autoload
- There is no Composer autoload configured for the app code in `composer.json`; tests include files directly. If you introduce autoloading, coordinate with Docker entrypoint behaviors and ensure zerodowntime for existing scripts.
- Performance & reliability hints for tests
- Prefer small, deterministic CLI scripts; inject dependencies and override I/O methods (as shown in `SelfservePermissionInitTest.php`).
- Avoid hitting external services (Stripe, economic, MinIO) from tests; instead, stub/override or provide fakes.
---
### Quick Reference
- Bring up minimal dev stack:
- `docker compose up -d traefik redis php1 caddy`
- Run a fast, selfcontained test (host PHP):
- `php services/nginx/app/tests/subusers/SelfservePermissionInitTest.php`
- Run a test inside Docker (envbacked):
- `docker compose exec -T php1 php /var/www/html/tests/subusers/SelfservePermissionInitTest.php`
Canonical workflow reference: `.ai-workflow/workflow.md`.
+45
View File
@@ -26,6 +26,32 @@ The API is accessible at:
- `https://localhost` (using Traefik default cert)
- `http(s)://localhost/api/` (proxied with `/api` prefix stripped)
### Test Gateway Container
To run a real PHP edge agent as a disposable Dockerized test gateway against the local stack, first create an install token from the edge gateway admin UI, then start the helper:
```powershell
.\scripts\test-gateway.ps1 start --install-token <token>
```
```bash
./scripts/test-gateway.sh start --install-token <token>
```
The helper will:
- start the local compose dependencies if needed
- claim a gateway through `http://localhost/api/edge-agent/claim`
- write the generated config to `.tmp/test-gateway/test-gateway.json`
- build `services/edge-agent/Dockerfile.test-gateway`
- run the PHP agent container on the local compose network
Useful follow-up commands:
```powershell
.\scripts\test-gateway.ps1 logs
.\scripts\test-gateway.ps1 status
.\scripts\test-gateway.ps1 stop
```
To start all services including multiple PHP workers and development tools (Jaeger, Portainer):
```powershell
docker compose --profile dev up -d
@@ -74,6 +100,25 @@ $env:RUN_INTEGRATION_TESTS='1'
composer test:integration
```
### Public Staging Edge-Gateway Smoke
`api.truckwash.io:4433` is the public staging ingress. For Edge Gateways v2, the router must serve the canonical artifacts from `services/nginx/app/resources/edge-gateway-agent`, not from the legacy `dist/agent.mjs` output or a separate runtime mount.
To verify the public staging stack after a deploy, use a real installer token and run:
```powershell
node .\scripts\staging-edge-gateway-smoke.mjs --install-token <token>
```
```bash
node ./scripts/staging-edge-gateway-smoke.mjs --install-token <token>
```
The smoke check fails unless all of these return `200` from the public domain:
- `/ping`
- `/edge-agent/artifacts/agent.php`
- `/edge-agent/artifacts/truckwash-edge-agent.service`
- `/edge-agent/install.sh?token=<real token>`
### API Test Suite
The `Api` suite exercises real HTTP endpoints instead of calling route handlers in-process.
-2
View File
@@ -101,7 +101,6 @@ services:
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
@@ -123,7 +122,6 @@ services:
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
n8n:
+2 -40
View File
@@ -80,16 +80,6 @@ services:
retries: 10
start_period: 20s
edge-broker:
build:
context: .
dockerfile: services/edge-broker/Dockerfile
container_name: edge-broker
environment:
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
ports:
- "4300:4300"
caddy:
image: caddy:2.7.6-alpine
container_name: caddy
@@ -159,7 +149,7 @@ services:
depends_on:
- php-staging
volumes:
- ./services/nginx/staging:/var/www/html
- ./services/nginx/app:/var/www/html
- ./services/caddy/Caddyfile-staging:/etc/caddy/Caddyfile:ro
- ./services/caddy/logs-staging:/var/log/caddy
labels:
@@ -206,17 +196,13 @@ services:
container_name: php1
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "true"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
@@ -227,17 +213,13 @@ services:
container_name: php2
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
@@ -248,17 +230,13 @@ services:
container_name: php3
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
@@ -269,17 +247,13 @@ services:
container_name: php4
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
@@ -290,17 +264,13 @@ services:
container_name: php5
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
@@ -311,17 +281,13 @@ services:
container_name: php-staging
depends_on:
- redis-staging
- edge-broker
command: ["php-fpm"]
env_file:
- .env.staging
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/staging:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs-staging:/var/log/php
@@ -332,17 +298,13 @@ services:
container_name: php-cron
depends_on:
- redis
- edge-broker
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
portainer:
+13 -1
View File
@@ -2195,6 +2195,17 @@ paths:
two_factor_enabled:
type: boolean
description: Indicates if 2FA is enabled for this account
runtime_config:
type: object
properties:
economic:
type: object
properties:
transaction_draft_customer_number:
type: integer
nullable: true
additionalProperties: false
additionalProperties: true
'400':
$ref: '#/components/responses/BadRequest'
'401':
@@ -12031,12 +12042,13 @@ components:
type: object
properties:
module: { type: string, enum: [economic] }
variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber] }
variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber, transactionDraftCustomerNumber] }
type: { type: string, enum: [string, int] }
value:
oneOf:
- type: string
- type: integer
nullable: true
required: [module, variable, type, value]
RecaptchaConfigEntry:
+138
View File
@@ -0,0 +1,138 @@
import process from "node:process";
import path from "node:path";
import { pathToFileURL } from "node:url";
export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433";
export function normalizeBaseUrl(url) {
return String(url || "").trim().replace(/\/+$/, "");
}
export function parseArgs(argv = process.argv.slice(2)) {
const options = {
baseUrl: DEFAULT_STAGING_BASE_URL,
installToken: "",
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const next = argv[index + 1];
switch (arg) {
case "--base-url":
options.baseUrl = String(next || "").trim() || DEFAULT_STAGING_BASE_URL;
index += 1;
break;
case "--install-token":
options.installToken = String(next || "").trim();
index += 1;
break;
case "--help":
case "-h":
options.help = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
export function buildChecks(baseUrl, installToken) {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
return [
{
name: "Ping",
url: `${normalizedBaseUrl}/ping`,
},
{
name: "Agent PHP artifact",
url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`,
},
{
name: "Service unit artifact",
url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`,
},
{
name: "Installer script",
url: `${normalizedBaseUrl}/edge-agent/install.sh?token=${encodeURIComponent(installToken)}`,
},
];
}
function printUsage() {
process.stdout.write(`Usage:
node scripts/staging-edge-gateway-smoke.mjs --install-token <token> [--base-url <url>]
Options:
--install-token <token> Real edge-gateway install token used to validate install.sh.
--base-url <url> Public staging base URL. Default: ${DEFAULT_STAGING_BASE_URL}
--help Show this help text.
`);
}
function previewBody(body, limit = 200) {
const normalized = String(body || "").replace(/\s+/g, " ").trim();
if (normalized.length <= limit) {
return normalized;
}
return `${normalized.slice(0, limit)}...`;
}
export async function runSmoke({ baseUrl, installToken }) {
if (String(installToken || "").trim() === "") {
throw new Error("Missing required --install-token value.");
}
const checks = buildChecks(baseUrl, installToken);
const results = [];
for (const check of checks) {
process.stdout.write(`[staging-smoke] GET ${check.url}\n`);
const response = await fetch(check.url);
const body = await response.text();
const result = {
...check,
status: response.status,
ok: response.ok,
bodyPreview: previewBody(body),
};
results.push(result);
if (!response.ok) {
throw new Error(
`${check.name} failed with HTTP ${response.status} at ${check.url}. ` +
`Body preview: ${result.bodyPreview || "<empty>"}`
);
}
}
return results;
}
async function main() {
const options = parseArgs();
if (options.help) {
printUsage();
return;
}
const results = await runSmoke(options);
for (const result of results) {
process.stdout.write(`[staging-smoke] OK ${result.status} ${result.name}\n`);
}
}
const isDirectExecution = process.argv[1]
&& import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
if (isDirectExecution) {
main().catch((error) => {
process.stderr.write(`[staging-smoke] ERROR: ${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
}
@@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
DEFAULT_STAGING_BASE_URL,
buildChecks,
normalizeBaseUrl,
parseArgs,
} from "./staging-edge-gateway-smoke.mjs";
test("normalizeBaseUrl strips trailing slashes", () => {
assert.equal(normalizeBaseUrl("https://api.truckwash.io:4433///"), DEFAULT_STAGING_BASE_URL);
});
test("parseArgs accepts base URL and install token", () => {
const options = parseArgs([
"--base-url",
"https://staging.example.test/",
"--install-token",
"token-123",
]);
assert.equal(options.baseUrl, "https://staging.example.test/");
assert.equal(options.installToken, "token-123");
assert.equal(options.help, false);
});
test("buildChecks targets the public staging endpoints", () => {
const checks = buildChecks(DEFAULT_STAGING_BASE_URL, "abc 123");
assert.deepEqual(checks.map((check) => check.url), [
"https://api.truckwash.io:4433/ping",
"https://api.truckwash.io:4433/edge-agent/artifacts/agent.php",
"https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service",
"https://api.truckwash.io:4433/edge-agent/install.sh?token=abc%20123",
]);
});
+414
View File
@@ -0,0 +1,414 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const scriptSource = await fs.readFile(__filename, "utf8");
async function main() {
const args = parseArgs(process.argv.slice(2));
const workspaceRoot = path.resolve(args.root ?? path.join(__dirname, ".."));
const workflowPath = path.join(workspaceRoot, ".ai-workflow", "workflow.md");
const manifestPath = path.join(workspaceRoot, ".ai-workflow", "manifest.json");
const workflow = await fs.readFile(workflowPath, "utf8");
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
validateManifest(manifest);
const outputs = buildOutputs({
manifest,
workflow,
scriptSource,
});
if (args.mode === "check") {
const drifted = await findDriftedOutputs(workspaceRoot, outputs);
if (drifted.length > 0) {
console.error("AI workflow outputs are out of sync:");
for (const drift of drifted) {
console.error(`- ${drift}`);
}
process.exit(1);
}
console.log("AI workflow outputs are in sync.");
return;
}
const changed = await writeOutputs(workspaceRoot, outputs);
if (changed.length === 0) {
console.log("AI workflow outputs are already up to date.");
return;
}
console.log("Updated AI workflow outputs:");
for (const changedPath of changed) {
console.log(`- ${changedPath}`);
}
}
function parseArgs(argv) {
const args = {
mode: null,
root: null,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--write") {
args.mode = "write";
continue;
}
if (arg === "--check") {
args.mode = "check";
continue;
}
if (arg === "--root") {
args.root = argv[index + 1];
index += 1;
continue;
}
throw new Error(`Unsupported argument: ${arg}`);
}
if (!args.mode) {
throw new Error("Expected --write or --check.");
}
return args;
}
function validateManifest(manifest) {
const requiredKeys = ["projects", "assistants", "commands", "generated_outputs", "sync_targets"];
for (const key of requiredKeys) {
if (!(key in manifest)) {
throw new Error(`Manifest is missing required key: ${key}`);
}
}
const seenPaths = new Set();
for (const output of manifest.generated_outputs) {
if (!output.path || !output.template) {
throw new Error("Each generated output must define path and template.");
}
if (seenPaths.has(output.path)) {
throw new Error(`Duplicate generated output path: ${output.path}`);
}
seenPaths.add(output.path);
}
}
function buildOutputs(context) {
const outputs = [];
for (const output of context.manifest.generated_outputs) {
outputs.push({
path: normalizeRelativePath(output.path),
content: renderTemplate(output, context),
});
}
return outputs;
}
function renderTemplate(output, context) {
const templateMap = {
codex_workspace_environment: () => renderWorkspaceCodexEnvironment(context.manifest),
codex_project_environment: () => renderProjectCodexEnvironment(context.manifest, output.project),
aiassistant_backend_tests_rule: () =>
renderAiAssistantRule(context.manifest.projects["backend-php"].generated_content.aiassistant_tests_lines),
aiassistant_backend_routes_rule: () =>
renderAiAssistantRule(context.manifest.projects["backend-php"].generated_content.aiassistant_routes_lines),
aiassistant_frontend_tests_rule: () =>
renderAiAssistantRule(context.manifest.projects["front-end-vue"].generated_content.aiassistant_tests_lines),
junie_backend_guidelines: () =>
renderJunieGuidelines(context.manifest.projects["backend-php"].generated_content.junie_lines),
junie_frontend_guidelines: () =>
renderJunieGuidelines(context.manifest.projects["front-end-vue"].generated_content.junie_lines),
copilot_dispatcher_workflow: () => renderCopilotWorkflow(context.manifest.copilot),
workflow_snapshot: () => renderWorkflowSnapshot(context.workflow, output.project),
project_snapshot_manifest: () => renderProjectSnapshotManifest(context.manifest, output.project),
project_sync_script: () => context.scriptSource,
};
const renderer = templateMap[output.template];
if (!renderer) {
throw new Error(`Unsupported template: ${output.template}`);
}
return ensureTrailingNewline(renderer());
}
function renderWorkspaceCodexEnvironment(manifest) {
return renderCodexEnvironment({
name: manifest.workspace.name,
setup: manifest.workspace.codex_environment.setup,
actions: manifest.workspace.codex_environment.actions,
manifest,
workspaceScoped: true,
});
}
function renderProjectCodexEnvironment(manifest, projectKey) {
const project = manifest.projects[projectKey];
return renderCodexEnvironment({
name: project.codex_environment.name,
setup: project.commands.setup,
actions: project.codex_environment.actions,
manifest,
projectKey,
workspaceScoped: false,
});
}
function renderCodexEnvironment({ name, setup, actions, manifest, projectKey = null, workspaceScoped }) {
const lines = [
"# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY",
"version = 1",
`name = "${name}"`,
"",
];
appendTomlCommandBlock(lines, "setup", setup);
for (const action of actions) {
lines.push("");
lines.push("[[actions]]");
lines.push(`name = "${escapeTomlString(action.name)}"`);
lines.push(`icon = "${escapeTomlString(action.icon)}"`);
lines.push(`command = ${renderTomlMultiline(resolveActionCommand(action, manifest, projectKey, workspaceScoped))}`);
if (action.platform) {
lines.push(`platform = "${escapeTomlString(action.platform)}"`);
}
}
return lines.join("\n");
}
function appendTomlCommandBlock(lines, key, commandSpec = {}) {
lines.push(`[${key}]`);
lines.push(`script = ${renderTomlMultiline(commandSpec.default ?? "")}`);
if (commandSpec.win32 !== undefined) {
lines.push("");
lines.push(`[${key}.win32]`);
lines.push(`script = ${renderTomlMultiline(commandSpec.win32)}`);
}
}
function resolveActionCommand(action, manifest, projectKey, workspaceScoped) {
if (action.literal_command) {
return action.literal_command.default ?? "";
}
if (!action.command_id) {
throw new Error(`Action ${action.name} is missing command_id or literal_command.`);
}
const resolvedProjectKey = action.project ?? projectKey;
if (!resolvedProjectKey) {
throw new Error(`Action ${action.name} does not resolve to a project.`);
}
const project = manifest.projects[resolvedProjectKey];
const command = project.commands[action.command_id]?.default ?? "";
if (!workspaceScoped) {
return command;
}
return joinCommands([`cd ${project.relative_root}`, command]);
}
function renderAiAssistantRule(lines) {
return [
"---",
"apply: always",
"---",
"",
"<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->",
"",
...lines,
].join("\n");
}
function renderJunieGuidelines(lines) {
return [
"<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->",
"",
...lines,
].join("\n");
}
function renderCopilotWorkflow(copilot) {
const body = copilot.body_lines
.map((line) => line.replaceAll("{{ACTOR}}", "$ACTOR").replaceAll("{{TASK}}", "$TASK"))
.join("\n");
return [
`name: ${copilot.workflow_name}`,
"on:",
" workflow_dispatch:",
" inputs:",
" task:",
` description: '${copilot.input_description}'`,
" required: true",
" type: string",
"",
"jobs:",
" assign-task:",
" runs-on: ubuntu-latest",
" permissions:",
" issues: write",
" steps:",
" - name: Checkout repository",
" uses: actions/checkout@v4",
"",
" - name: Create GitHub Issue for Copilot",
" env:",
" GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}",
" TASK: ${{ github.event.inputs.task }}",
" ACTOR: ${{ github.actor }}",
" run: |",
" BODY=\"$(cat <<EOF",
...body.split("\n").map((line) => ` ${line}`),
" EOF",
" )\"",
" gh issue create \\",
` --title "${copilot.title_prefix} $TASK" \\`,
" --body \"$BODY\" \\",
` --label "${copilot.issue_label}"`,
].join("\n");
}
function renderWorkflowSnapshot(workflow, projectKey) {
return [
`<!-- AUTOGENERATED SNAPSHOT for ${projectKey}: refresh from the canonical workspace with \`node scripts/sync-ai-workflow.mjs --write\`. -->`,
"",
workflow.trimEnd(),
].join("\n");
}
function renderProjectSnapshotManifest(manifest, projectKey) {
const snapshotManifest = buildProjectSnapshotManifest(manifest, projectKey);
return `${JSON.stringify(snapshotManifest, null, 2)}\n`;
}
function buildProjectSnapshotManifest(manifest, projectKey) {
const project = JSON.parse(JSON.stringify(manifest.projects[projectKey]));
const prefix = `${manifest.projects[projectKey].relative_root}/`;
project.relative_root = ".";
const snapshotOutputs = manifest.generated_outputs
.filter((output) => output.project === projectKey)
.filter((output) => output.template !== "workflow_snapshot")
.filter((output) => output.template !== "project_snapshot_manifest")
.filter((output) => output.template !== "project_sync_script")
.map((output) => ({
...output,
path: normalizeRelativePath(output.path.slice(prefix.length)),
}));
const snapshotManifest = {
"version": manifest.version,
"snapshot_of": projectKey,
"projects": {
[projectKey]: project,
},
"assistants": manifest.assistants,
"commands": manifest.commands,
"generated_outputs": snapshotOutputs,
"sync_targets": {
[projectKey]: {
"source_root": ".",
"destination": manifest.sync_targets[projectKey]?.destination ?? "",
"supported_metadata_dirs": manifest.sync_targets[projectKey]?.supported_metadata_dirs ?? [],
},
},
};
if (projectKey === "backend-php" && manifest.copilot) {
snapshotManifest.copilot = manifest.copilot;
}
if (projectKey === "front-end-vue" && manifest.unsupported) {
snapshotManifest.unsupported = manifest.unsupported;
}
return snapshotManifest;
}
async function findDriftedOutputs(workspaceRoot, outputs) {
const drifted = [];
for (const output of outputs) {
const absolutePath = path.join(workspaceRoot, output.path);
const currentContent = await readIfExists(absolutePath);
if (currentContent !== output.content) {
drifted.push(output.path);
}
}
return drifted;
}
async function writeOutputs(workspaceRoot, outputs) {
const changed = [];
for (const output of outputs) {
const absolutePath = path.join(workspaceRoot, output.path);
const currentContent = await readIfExists(absolutePath);
if (currentContent === output.content) {
continue;
}
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, output.content, "utf8");
changed.push(output.path);
}
return changed;
}
async function readIfExists(filePath) {
try {
return await fs.readFile(filePath, "utf8");
} catch (error) {
if (error && error.code === "ENOENT") {
return null;
}
throw error;
}
}
function joinCommands(commands) {
return commands.filter((command) => command && command.trim() !== "").join("\n");
}
function renderTomlMultiline(value) {
return `'''\n${value ?? ""}\n'''`;
}
function escapeTomlString(value) {
return String(value).replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
}
function normalizeRelativePath(filePath) {
return filePath.replaceAll("\\", "/");
}
function ensureTrailingNewline(value) {
return value.endsWith("\n") ? value : `${value}\n`;
}
main().catch((error) => {
console.error(error.message);
process.exit(1);
});
+480
View File
@@ -0,0 +1,480 @@
import { execFile as execFileCallback, spawn as spawnCallback } from "node:child_process";
import { promises as fs } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execFile = promisify(execFileCallback);
export const DEFAULT_CONTAINER_NAME = "truckwash-test-gateway";
export const DEFAULT_IMAGE_TAG = "truckwash-edge-agent:test-gateway";
export const DEFAULT_CONFIG_FILE_NAME = "test-gateway.json";
export const DEFAULT_HOST_API_URL = "http://localhost/api";
export const DEFAULT_CONTAINER_API_URL = "http://caddy";
export const DEFAULT_CONTAINER_BROKER_URL = "http://edge-broker:4300";
export const DEFAULT_INSTALL_DIR = "/opt/truckwash-edge-agent";
export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15;
export const DEFAULT_INSTALLED_VERSION = "php-agent-v1";
const DEFAULT_COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "caddy"];
function printUsage() {
process.stdout.write(`Usage:
node scripts/test-gateway.mjs start [--install-token <token>] [--container-name <name>] [--hostname <hostname>]
node scripts/test-gateway.mjs stop [--container-name <name>]
node scripts/test-gateway.mjs logs [--container-name <name>] [--tail <lines>]
node scripts/test-gateway.mjs status [--container-name <name>]
Options:
--install-token <token> Claim a new edge gateway before starting the container.
--container-name <name> Docker container name. Default: ${DEFAULT_CONTAINER_NAME}
--hostname <hostname> Gateway hostname reported during claim and Docker run.
--host-api-url <url> Host-reachable API URL for claim/health checks. Default: ${DEFAULT_HOST_API_URL}
--api-url <url> Container-internal API URL. Default: ${DEFAULT_CONTAINER_API_URL}
--broker-url <url> Container-internal broker URL. Default: ${DEFAULT_CONTAINER_BROKER_URL}
--config-dir <dir> Directory for generated config. Default: backend-php/.tmp/test-gateway
--image-tag <tag> Docker image tag. Default: ${DEFAULT_IMAGE_TAG}
--heartbeat-seconds <n> Agent heartbeat interval. Default: ${DEFAULT_HEARTBEAT_INTERVAL_SECONDS}
--tail <lines> Log lines for the logs action. Default: 200
--skip-compose-up Do not start the local Docker Compose stack before start.
--skip-build Do not rebuild the test gateway image before start.
`);
}
export function resolveComposeProjectName(rootDir, env = process.env) {
const explicit = String(env.COMPOSE_PROJECT_NAME || "").trim();
if (explicit !== "") {
return explicit;
}
return path.basename(rootDir);
}
export function resolveComposeNetworkName(rootDir, env = process.env) {
return `${resolveComposeProjectName(rootDir, env)}_default`;
}
export function resolveConfigDirectory(rootDir, explicitDir = null) {
if (explicitDir) {
return path.resolve(rootDir, explicitDir);
}
return path.join(rootDir, ".tmp", "test-gateway");
}
export function shouldClaimGateway(existingConfig = {}, installToken = "") {
if (String(installToken || "").trim() !== "") {
return true;
}
return !(existingConfig.gatewayId && existingConfig.agentToken);
}
export function buildGatewayConfig({
existingConfig = {},
claim = null,
apiUrl = DEFAULT_CONTAINER_API_URL,
brokerUrl = DEFAULT_CONTAINER_BROKER_URL,
hostname = DEFAULT_CONTAINER_NAME,
heartbeatIntervalSeconds = DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
} = {}) {
const installedVersion = String(existingConfig.installedVersion || DEFAULT_INSTALLED_VERSION);
const targetVersion = String(existingConfig.targetVersion || installedVersion);
return {
apiUrl,
brokerUrl,
gatewayId: claim?.gateway?.id ?? existingConfig.gatewayId ?? null,
agentToken: claim?.agent_token ?? existingConfig.agentToken ?? null,
hostname,
releaseChannel: claim?.release_channel ?? existingConfig.releaseChannel ?? "stable",
installedVersion,
targetVersion,
installDir: DEFAULT_INSTALL_DIR,
agentPath: `${DEFAULT_INSTALL_DIR}/agent.php`,
serviceUnitPath: `${DEFAULT_INSTALL_DIR}/truckwash-edge-agent.service`,
serviceName: String(existingConfig.serviceName || hostname || DEFAULT_CONTAINER_NAME),
restartMode: "spawn",
heartbeatIntervalSeconds,
commandPollTimeoutSeconds: Number(existingConfig.commandPollTimeoutSeconds || 20),
shellActionPollTimeoutSeconds: Number(existingConfig.shellActionPollTimeoutSeconds || 20),
commandPollRetryDelayMs: Number(existingConfig.commandPollRetryDelayMs || 1000),
shellActionPollRetryDelayMs: Number(existingConfig.shellActionPollRetryDelayMs || 1000),
brokerReconnectDelayMs: Number(existingConfig.brokerReconnectDelayMs || 1500),
};
}
export function parseArgs(argv = process.argv.slice(2)) {
const knownActions = new Set(["start", "stop", "logs", "status"]);
const [first = "", ...remaining] = argv;
const action = knownActions.has(first) ? first : "start";
const rest = knownActions.has(first) ? remaining : argv;
const options = {
action,
containerName: DEFAULT_CONTAINER_NAME,
imageTag: DEFAULT_IMAGE_TAG,
hostApiUrl: DEFAULT_HOST_API_URL,
apiUrl: DEFAULT_CONTAINER_API_URL,
brokerUrl: DEFAULT_CONTAINER_BROKER_URL,
hostname: DEFAULT_CONTAINER_NAME,
configDir: null,
installToken: "",
heartbeatSeconds: DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
tail: "200",
skipComposeUp: false,
skipBuild: false,
};
for (let index = 0; index < rest.length; index += 1) {
const arg = rest[index];
const next = rest[index + 1];
switch (arg) {
case "--container-name":
options.containerName = String(next || "").trim() || DEFAULT_CONTAINER_NAME;
options.hostname = options.containerName;
index += 1;
break;
case "--hostname":
options.hostname = String(next || "").trim() || options.hostname;
index += 1;
break;
case "--image-tag":
options.imageTag = String(next || "").trim() || DEFAULT_IMAGE_TAG;
index += 1;
break;
case "--host-api-url":
options.hostApiUrl = String(next || "").trim() || DEFAULT_HOST_API_URL;
index += 1;
break;
case "--api-url":
options.apiUrl = String(next || "").trim() || DEFAULT_CONTAINER_API_URL;
index += 1;
break;
case "--broker-url":
options.brokerUrl = String(next || "").trim() || DEFAULT_CONTAINER_BROKER_URL;
index += 1;
break;
case "--config-dir":
options.configDir = String(next || "").trim() || null;
index += 1;
break;
case "--install-token":
options.installToken = String(next || "").trim();
index += 1;
break;
case "--heartbeat-seconds":
options.heartbeatSeconds = Number(next || DEFAULT_HEARTBEAT_INTERVAL_SECONDS);
index += 1;
break;
case "--tail":
options.tail = String(next || "200").trim() || "200";
index += 1;
break;
case "--skip-compose-up":
options.skipComposeUp = true;
break;
case "--skip-build":
options.skipBuild = true;
break;
case "--help":
case "-h":
options.help = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
async function runCommand(command, args, { cwd, allowFailure = false, stdio = "pipe" } = {}) {
if (stdio === "inherit") {
await new Promise((resolve, reject) => {
const child = spawnCallback(command, args, {
cwd,
stdio: "inherit",
windowsHide: true,
});
child.on("exit", (code) => {
if (code === 0 || allowFailure) {
resolve();
return;
}
reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code}`));
});
child.on("error", reject);
});
return { stdout: "", stderr: "" };
}
try {
return await execFile(command, args, {
cwd,
windowsHide: true,
encoding: "utf8",
});
} catch (error) {
if (allowFailure) {
return {
stdout: error.stdout || "",
stderr: error.stderr || "",
code: error.code || 1,
};
}
throw error;
}
}
async function ensureComposeServices(rootDir, skipComposeUp) {
if (skipComposeUp) {
return;
}
await runCommand("docker", ["compose", "up", "-d", ...DEFAULT_COMPOSE_SERVICES], {
cwd: rootDir,
stdio: "inherit",
});
}
function normalizeBaseUrl(url) {
return String(url || "").replace(/\/+$/, "");
}
async function waitForApiReady(hostApiUrl, attempts = 60) {
const baseUrl = normalizeBaseUrl(hostApiUrl);
let lastError = null;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
const response = await fetch(`${baseUrl}/ping`);
if (response.ok) {
return;
}
lastError = new Error(`HTTP ${response.status}`);
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error(`API did not become ready at ${baseUrl}/ping: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
async function claimGateway(hostApiUrl, installToken, hostname) {
const response = await fetch(`${normalizeBaseUrl(hostApiUrl)}/edge-agent/claim`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
token: installToken,
hostname,
installed_version: DEFAULT_INSTALLED_VERSION,
metadata: {
source: "docker-test-gateway-script",
},
}),
});
const json = await response.json();
if (!response.ok) {
throw new Error(json?.data?.message || json?.message || `HTTP ${response.status}`);
}
return json.data ?? json;
}
async function readConfig(configFilePath) {
try {
const raw = await fs.readFile(configFilePath, "utf8");
return JSON.parse(raw);
} catch (error) {
if (error?.code === "ENOENT") {
return {};
}
throw error;
}
}
async function writeConfig(configFilePath, config) {
await fs.mkdir(path.dirname(configFilePath), { recursive: true });
await fs.writeFile(configFilePath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
}
function toDockerMountPath(hostPath) {
return hostPath.replace(/\\/g, "/");
}
async function ensureImage(rootDir, imageTag, skipBuild) {
if (skipBuild) {
return;
}
await runCommand(
"docker",
["build", "-t", imageTag, "-f", "services/edge-agent/Dockerfile.test-gateway", "."],
{
cwd: rootDir,
stdio: "inherit",
}
);
}
async function removeContainer(containerName) {
await runCommand("docker", ["rm", "-f", containerName], { allowFailure: true });
}
async function startContainer({
rootDir,
imageTag,
containerName,
hostname,
configDir,
}) {
const networkName = resolveComposeNetworkName(rootDir);
const mountedConfigDir = toDockerMountPath(configDir);
await removeContainer(containerName);
await runCommand(
"docker",
[
"run",
"-d",
"--name",
containerName,
"--hostname",
hostname,
"--restart",
"unless-stopped",
"--network",
networkName,
"-v",
`${mountedConfigDir}:/config`,
imageTag,
"--config",
`/config/${DEFAULT_CONFIG_FILE_NAME}`,
],
{
cwd: rootDir,
stdio: "inherit",
}
);
}
async function printStatus({ imageTag, configDir, containerName }) {
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
const config = await readConfig(configFilePath);
const inspection = await runCommand("docker", ["inspect", containerName], {
allowFailure: true,
});
let container = null;
if (inspection.stdout && String(inspection.stdout).trim() !== "") {
try {
const decoded = JSON.parse(inspection.stdout);
container = Array.isArray(decoded) ? decoded[0] ?? null : null;
} catch {
container = null;
}
}
process.stdout.write(`${JSON.stringify({
containerName,
configFilePath,
gatewayId: config.gatewayId ?? null,
installDir: config.installDir ?? null,
agentPath: config.agentPath ?? null,
serviceUnitPath: config.serviceUnitPath ?? null,
containerStatus: container?.State?.Status ?? "missing",
running: Boolean(container?.State?.Running),
image: container?.Config?.Image ?? imageTag,
}, null, 2)}\n`);
}
async function main() {
const options = parseArgs();
if (options.help) {
printUsage();
return;
}
const scriptPath = fileURLToPath(import.meta.url);
const rootDir = path.resolve(path.dirname(scriptPath), "..");
const configDir = resolveConfigDirectory(rootDir, options.configDir);
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
switch (options.action) {
case "start": {
await ensureComposeServices(rootDir, options.skipComposeUp);
await waitForApiReady(options.hostApiUrl);
await ensureImage(rootDir, options.imageTag, options.skipBuild);
const existingConfig = await readConfig(configFilePath);
let claim = null;
if (shouldClaimGateway(existingConfig, options.installToken)) {
if (String(options.installToken || "").trim() === "") {
throw new Error(`An install token is required to create the first test gateway config at ${configFilePath}`);
}
claim = await claimGateway(options.hostApiUrl, options.installToken, options.hostname);
}
const config = buildGatewayConfig({
existingConfig,
claim,
apiUrl: options.apiUrl,
brokerUrl: options.brokerUrl,
hostname: options.hostname,
heartbeatIntervalSeconds: Number.isFinite(options.heartbeatSeconds) && options.heartbeatSeconds > 0
? Math.round(options.heartbeatSeconds)
: DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
});
await writeConfig(configFilePath, config);
await startContainer({
rootDir,
imageTag: options.imageTag,
containerName: options.containerName,
hostname: options.hostname,
configDir,
});
process.stdout.write(`Test gateway container started.
Container: ${options.containerName}
Config: ${configFilePath}
Gateway ID: ${config.gatewayId ?? "unclaimed"}
`);
return;
}
case "stop":
await removeContainer(options.containerName);
process.stdout.write(`Removed container ${options.containerName}\n`);
return;
case "logs":
await runCommand("docker", ["logs", "-f", "--tail", options.tail, options.containerName], {
stdio: "inherit",
});
return;
case "status":
await printStatus({
imageTag: options.imageTag,
configDir,
containerName: options.containerName,
});
return;
default:
throw new Error(`Unsupported action: ${options.action}`);
}
}
const currentFilePath = fileURLToPath(import.meta.url);
const invokedScript = process.argv[1] ? path.resolve(process.argv[1]) : "";
if (invokedScript === path.resolve(currentFilePath)) {
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
}
+6
View File
@@ -0,0 +1,6 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
& node (Join-Path $scriptDir "test-gateway.mjs") @args
exit $LASTEXITCODE
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
node "$SCRIPT_DIR/test-gateway.mjs" "$@"
+91
View File
@@ -0,0 +1,91 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
DEFAULT_CONTAINER_NAME,
DEFAULT_CONTAINER_API_URL,
DEFAULT_CONTAINER_BROKER_URL,
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
buildGatewayConfig,
parseArgs,
resolveComposeNetworkName,
resolveComposeProjectName,
resolveConfigDirectory,
shouldClaimGateway,
} from "./test-gateway.mjs";
test("resolveComposeProjectName prefers COMPOSE_PROJECT_NAME", () => {
assert.equal(
resolveComposeProjectName("C:/Users/test/backend-php", { COMPOSE_PROJECT_NAME: "custom-stack" }),
"custom-stack"
);
});
test("resolveComposeProjectName falls back to backend directory name", () => {
assert.equal(resolveComposeProjectName("C:/Users/test/backend-php", {}), "backend-php");
});
test("resolveComposeNetworkName derives the default compose network", () => {
assert.equal(
resolveComposeNetworkName("C:/Users/test/backend-php", { COMPOSE_PROJECT_NAME: "custom-stack" }),
"custom-stack_default"
);
});
test("resolveConfigDirectory uses the default temp folder when none is provided", () => {
assert.equal(
resolveConfigDirectory("C:/Users/test/backend-php"),
"C:\\Users\\test\\backend-php\\.tmp\\test-gateway"
);
});
test("shouldClaimGateway requires a token when no credentials are present", () => {
assert.equal(shouldClaimGateway({}, ""), true);
assert.equal(shouldClaimGateway({ gatewayId: 12, agentToken: "secret" }, ""), false);
assert.equal(shouldClaimGateway({ gatewayId: 12, agentToken: "secret" }, "fresh-token"), true);
});
test("parseArgs accepts help without an explicit action", () => {
const options = parseArgs(["--help"]);
assert.equal(options.action, "start");
assert.equal(options.help, true);
});
test("buildGatewayConfig applies defaults for a dockerized gateway", () => {
const config = buildGatewayConfig({});
assert.equal(config.apiUrl, DEFAULT_CONTAINER_API_URL);
assert.equal(config.brokerUrl, DEFAULT_CONTAINER_BROKER_URL);
assert.equal(config.hostname, DEFAULT_CONTAINER_NAME);
assert.equal(config.restartMode, "spawn");
assert.equal(config.heartbeatIntervalSeconds, DEFAULT_HEARTBEAT_INTERVAL_SECONDS);
assert.equal(config.installDir, "/opt/truckwash-edge-agent");
assert.equal(config.agentPath, "/opt/truckwash-edge-agent/agent.php");
assert.equal(config.serviceUnitPath, "/opt/truckwash-edge-agent/truckwash-edge-agent.service");
assert.equal(config.serviceName, DEFAULT_CONTAINER_NAME);
});
test("buildGatewayConfig folds claim response into the generated config", () => {
const config = buildGatewayConfig({
existingConfig: {
installedVersion: "0.9.0",
targetVersion: "0.9.1",
},
hostname: "gw-docker-1",
claim: {
gateway: { id: 44 },
agent_token: "agent-secret",
release_channel: "stable",
},
heartbeatIntervalSeconds: 7,
});
assert.equal(config.gatewayId, 44);
assert.equal(config.agentToken, "agent-secret");
assert.equal(config.releaseChannel, "stable");
assert.equal(config.hostname, "gw-docker-1");
assert.equal(config.installedVersion, "0.9.0");
assert.equal(config.targetVersion, "0.9.1");
assert.equal(config.heartbeatIntervalSeconds, 7);
});
@@ -0,0 +1,16 @@
FROM php:8.2-cli
WORKDIR /opt/truckwash-edge-agent
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
libcurl4-openssl-dev \
ca-certificates; \
docker-php-ext-install curl; \
rm -rf /var/lib/apt/lists/*
COPY services/nginx/app/resources/edge-gateway-agent/ ./
ENTRYPOINT ["php", "/opt/truckwash-edge-agent/agent.php"]
CMD ["--config", "/config/test-gateway.json"]
+9 -34
View File
@@ -137,7 +137,6 @@ function buildTransportHeartbeatState(brokerState = {}) {
status: "ONLINE",
metadata: {
command_transport: brokerConnected ? "BROKER_FAST_PATH" : "API_POLLING",
shell_transport: "API_POLLING",
broker_connected: brokerConnected,
broker_url: brokerState.url || null,
broker_last_error: brokerState.lastError || null,
@@ -1763,20 +1762,21 @@ export async function startAgent({
config.configPath = configPath;
const intervalMs = Number(config.heartbeatIntervalSeconds || 15) * 1000;
const commandPollTimeoutSeconds = Number(config.commandPollTimeoutSeconds || 20);
const shellActionPollTimeoutSeconds = Number(config.shellActionPollTimeoutSeconds || 20);
const commandPollRetryDelayMs = Number(config.commandPollRetryDelayMs || 1000);
const shellActionPollRetryDelayMs = Number(config.shellActionPollRetryDelayMs || 1000);
const brokerReconnectDelayMs = Number(config.brokerReconnectDelayMs || DEFAULT_BROKER_RECONNECT_DELAY_MS);
let stopped = false;
let cpuSnapshot = null;
let lastHeartbeatLatencyMs = null;
const shellEventPublisher = createShellEventPublisher(config, fetchImpl);
void createShellBridgeImpl;
let brokerBridge = null;
const shell = createShellBridgeImpl((message) => {
shellEventPublisher.publish(message);
brokerBridge?.send(message);
});
const shell = {
open: async () => {},
input: () => {},
resize: () => {},
close: () => {},
dispose: () => {},
};
brokerBridge = createBrokerBridge({
config,
shell,
@@ -1841,32 +1841,8 @@ export async function startAgent({
}
};
const runShellActionPollLoop = async () => {
while (!stopped) {
try {
const action = await pollShellActionJob(config, fetchImpl, shellActionPollTimeoutSeconds);
if (stopped) {
break;
}
if (!action) {
continue;
}
await processPolledShellAction(config, action, shell, fetchImpl);
} catch {
if (stopped) {
break;
}
await new Promise((resolve) => setTimeout(resolve, shellActionPollRetryDelayMs));
}
}
};
await sendTransportHeartbeat();
const commandPollPromise = runCommandPollLoop();
const shellActionPollPromise = runShellActionPollLoop();
const timer = setInterval(() => {
sendTransportHeartbeat().catch(() => {});
@@ -1878,8 +1854,7 @@ export async function startAgent({
clearInterval(timer);
brokerBridge?.stop();
shell.dispose();
await shellEventPublisher.drain().catch(() => {});
await Promise.allSettled([commandPollPromise, shellActionPollPromise]);
await Promise.allSettled([commandPollPromise]);
};
return {
File diff suppressed because it is too large Load Diff
+33
View File
@@ -29,6 +29,8 @@ use interfaces\economic_i;
class economic implements economic_i
{
public const DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE = 'Transactions for the configured draft customer cannot be exported to e-conomic.';
/**
* Configuration of the economic module
* @var economic_c
@@ -120,6 +122,37 @@ class economic implements economic_i
return new $this->helpers->economic_tasks();
}
public function getTransactionDraftCustomerNumber(): ?int
{
$value = $this->config->transaction_draft_customer_number->getVariableValue();
if ($value === null || $value === '') {
return null;
}
$customer_number = (int)$value;
return $customer_number > 0 ? $customer_number : null;
}
public function isDraftCustomerNumber(?int $customer_number): bool
{
$configured_customer_number = $this->getTransactionDraftCustomerNumber();
if ($configured_customer_number === null || $customer_number === null) {
return false;
}
return $configured_customer_number === (int)$customer_number;
}
/**
* @throws \Exception
*/
public function assertCustomerNumberIsNotDraft(?int $customer_number): void
{
if ($this->isDraftCustomerNumber($customer_number)) {
throw new \Exception(self::DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE);
}
}
/**
* Create a customer in e-conomic and return the raw upstream payload.
*/
@@ -23,6 +23,7 @@ class economic_transfer_executor
*/
public function exportOrderDraftInvoice(int $order_id, int $user_id = 0): array
{
$economic = new economic();
$order = (new orders_o())->getOrderById($order_id);
if (!$order->exists()) {
throw new Exception('Order not found');
@@ -44,6 +45,7 @@ class economic_transfer_executor
if (!$customer->exists()) {
throw new Exception('Customer not found');
}
$economic->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
$customer_economic = $customer->getCustomerEcocomicData()->economic_customer;
$economic_invoice_draft = new economic_invoice_draft_mo();
@@ -128,6 +130,7 @@ class economic_transfer_executor
*/
public function exportOrderInvoice(int $order_id, int $user_id = 0): array
{
$economic = new economic();
$order = (new orders_o())->getOrderById($order_id);
if (!$order->exists()) {
throw new Exception('Order not found');
@@ -137,6 +140,7 @@ class economic_transfer_executor
if (!$customer->exists()) {
throw new Exception('Customer not found');
}
$economic->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
@@ -189,6 +193,7 @@ class economic_transfer_executor
{
$collected_order_invoices = (new collected_order_invoices_o())->select($collected_invoice_id);
$collected_order_invoices->requireSelected();
(new economic())->assertCustomerNumberIsNotDraft((int)$collected_order_invoices->customer_number->value());
if ($collected_order_invoices->external_id->value() === null) {
if (!$send_as_is) {
@@ -0,0 +1,145 @@
<?php
namespace classes;
use Exception;
class edge_gateway_agent_artifact_locator
{
private const ROUTER_ARTIFACT_DIRECTORY = 'resources/edge-gateway-agent';
private const DEFAULT_MOUNTED_ARTIFACT_DIRECTORY = '/services/edge-agent/php-agent';
private const DEFAULT_BAKED_ARTIFACT_DIRECTORY = '/opt/truckwash-edge-agent-artifacts';
/**
* @return array<int,string>
*/
public static function candidatePaths(
string $fileName,
?string $basePath = null,
?string $mountedArtifactDirectory = null,
?string $bakedArtifactDirectory = null
): array
{
$basePath = self::normalizePath($basePath ?? WD);
$candidateDirectories = [];
$configuredDirectory = trim((string)(getenv('EDGE_AGENT_ARTIFACT_DIR') ?: ''));
if ($configuredDirectory !== '') {
$candidateDirectories[] = self::normalizePath($configuredDirectory);
}
$candidateDirectories[] = self::routerArtifactDirectory($basePath);
$mountedArtifactDirectory = $mountedArtifactDirectory ?? self::mountedArtifactDirectory();
if ($mountedArtifactDirectory !== null) {
$candidateDirectories[] = self::normalizePath($mountedArtifactDirectory);
}
$bakedArtifactDirectory = $bakedArtifactDirectory ?? self::bakedArtifactDirectory();
if ($bakedArtifactDirectory !== null) {
$candidateDirectories[] = self::normalizePath($bakedArtifactDirectory);
}
$candidateDirectories[] = self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
$candidateDirectories[] = self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
$candidateDirectories[] = self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent');
$paths = [];
foreach (array_values(array_unique($candidateDirectories)) as $directory) {
$paths[] = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $fileName;
}
return array_values(array_unique($paths));
}
/**
* @throws Exception
*/
public static function resolve(
string $fileName,
?string $basePath = null,
?string $mountedArtifactDirectory = null,
?string $bakedArtifactDirectory = null
): string
{
$candidatePaths = self::candidatePaths($fileName, $basePath, $mountedArtifactDirectory, $bakedArtifactDirectory);
foreach ($candidatePaths as $path) {
if (is_file($path)) {
return $path;
}
}
$message = 'Missing edge agent artifact: ' . $fileName . '.';
$message .= ' Checked paths: ' . implode(', ', $candidatePaths) . '.';
$message .= ' Deploy the router-owned artifacts under ' . self::routerArtifactDirectory($basePath) . '.';
$legacyNodeArtifact = self::legacyNodeArtifactPath($basePath);
if ($legacyNodeArtifact !== null) {
$message .= ' Legacy Node dist artifact found at ' . $legacyNodeArtifact . '.';
$message .= ' Remove /services/edge-agent/dist and deploy the router resources instead of depending on the legacy mount.';
}
throw new Exception($message);
}
private static function normalizePath(string $path): string
{
$normalized = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
if (DIRECTORY_SEPARATOR === '/') {
$normalized = preg_replace('#/+#', '/', $normalized) ?: $normalized;
}
$trimmed = rtrim($normalized, DIRECTORY_SEPARATOR);
if ($trimmed === '' && str_starts_with($normalized, DIRECTORY_SEPARATOR)) {
return DIRECTORY_SEPARATOR;
}
return $trimmed;
}
private static function routerArtifactDirectory(string $basePath): string
{
return self::normalizePath($basePath . DIRECTORY_SEPARATOR . self::ROUTER_ARTIFACT_DIRECTORY);
}
private static function mountedArtifactDirectory(): ?string
{
if (DIRECTORY_SEPARATOR !== '/') {
return null;
}
return self::DEFAULT_MOUNTED_ARTIFACT_DIRECTORY;
}
private static function bakedArtifactDirectory(): ?string
{
if (DIRECTORY_SEPARATOR !== '/') {
return null;
}
return self::DEFAULT_BAKED_ARTIFACT_DIRECTORY;
}
private static function legacyNodeArtifactPath(?string $basePath = null): ?string
{
$basePath = self::normalizePath($basePath ?? WD);
$candidateDirectories = [
dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'dist',
dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'dist',
dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'dist',
];
if (DIRECTORY_SEPARATOR === '/') {
$candidateDirectories[] = '/services/edge-agent/dist';
}
foreach (array_values(array_unique(array_map(static fn(string $directory): string => self::normalizePath($directory), $candidateDirectories))) as $directory) {
$path = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'agent.mjs';
if (is_file($path)) {
return $path;
}
}
return null;
}
}
@@ -0,0 +1,72 @@
<?php
namespace classes;
use Exception;
class edge_gateway_install_service
{
private const ARTIFACTS = [
'agent.php' => 'application/x-httpd-php; charset=utf-8',
'truckwash-edge-agent.service' => 'text/plain; charset=utf-8',
];
public function __construct(private readonly ?edge_gateway_manager $manager = null)
{
edge_gateway_schema_bootstrap::ensureTables();
}
public function buildInstallScript(string $plainToken): string
{
return $this->manager()->buildInstallScript($plainToken);
}
/**
* @throws Exception
*/
public function verifyInstallToken(string $plainToken): array
{
return $this->manager()->verifyInstallToken($plainToken);
}
/**
* @throws Exception
*/
public function readArtifact(string $fileName): string
{
$path = $this->artifactPath($fileName);
$contents = file_get_contents($path);
if ($contents === false) {
throw new Exception('Unable to read edge agent artifact');
}
return $contents;
}
public function contentType(string $fileName): string
{
return self::ARTIFACTS[$fileName] ?? 'application/octet-stream';
}
public function buildArtifactUrl(string $fileName): string
{
return rtrim($this->manager()->getApiBaseUrl(), '/') . '/edge-agent/artifacts/' . $fileName;
}
/**
* @throws Exception
*/
public function artifactPath(string $fileName): string
{
if (!array_key_exists($fileName, self::ARTIFACTS)) {
throw new Exception('Unknown edge agent artifact');
}
return edge_gateway_agent_artifact_locator::resolve($fileName);
}
private function manager(): edge_gateway_manager
{
return $this->manager ?? new edge_gateway_manager();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
<?php
namespace classes;
use Exception;
class edge_gateway_operation_exception extends Exception
{
public function __construct(
string $message,
public readonly string $errorCode,
public readonly int $status = 400
) {
parent::__construct($message, $status);
}
}
@@ -0,0 +1,767 @@
<?php
namespace classes;
use Exception;
use objects\edge_gateway_operation_events_o;
use objects\edge_gateway_operations_o;
use objects\edge_gateways_o;
class edge_gateway_operation_service
{
public const TYPE_DISCOVERY = 'DISCOVERY';
public const TYPE_UPDATE = 'UPDATE';
public const TYPE_UNINSTALL = 'UNINSTALL';
public const STATUS_PENDING = 'PENDING';
public const STATUS_IN_PROGRESS = 'IN_PROGRESS';
public const STATUS_COMPLETED = 'COMPLETED';
public const STATUS_FAILED = 'FAILED';
public const LEVEL_INFO = 'INFO';
public const LEVEL_WARNING = 'WARNING';
public const LEVEL_ERROR = 'ERROR';
public const ERROR_OFFLINE = 'EDGE_GATEWAY_OFFLINE';
public const ERROR_STALE_HEARTBEAT = 'EDGE_GATEWAY_STALE_HEARTBEAT';
public const ERROR_INVALID_TOKEN = 'EDGE_GATEWAY_INVALID_TOKEN';
public const ERROR_OPERATION_TIMEOUT = 'EDGE_GATEWAY_OPERATION_TIMEOUT';
public const ERROR_UNSUPPORTED_VERSION = 'EDGE_GATEWAY_UNSUPPORTED_VERSION';
public const ERROR_CONFLICT = 'EDGE_GATEWAY_CONFLICT';
public const ERROR_VALIDATION = 'EDGE_GATEWAY_VALIDATION_FAILED';
public const POLL_INTERVAL_MICROSECONDS = 250000;
public const OPERATION_TIMEOUT_SECONDS = 900;
public const OPERATION_LEASE_SECONDS = 45;
public function __construct(private readonly ?edge_gateway_manager $manager = null)
{
edge_gateway_schema_bootstrap::ensureTables();
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function listOperations(int $gatewayId, int $limit = 20, bool $includeEvents = true): array
{
$this->requireGateway($gatewayId);
$this->failTimedOutOperations($gatewayId);
$rows = (new edge_gateway_operations_o())->getFieldsWhere([
'gateway_id' => $gatewayId,
'deleted_at' => null,
], ['id']);
$ids = array_map(static fn(array $row): int => (int)$row['id'], $rows);
rsort($ids);
$ids = array_slice($ids, 0, max(1, $limit));
$operations = [];
foreach ($ids as $id) {
$operations[] = $this->serializeOperation((new edge_gateway_operations_o())->select($id), $includeEvents);
}
return $operations;
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function listOperationEvents(int $gatewayId, int $operationId, int $limit = 100): array
{
$this->requireOperation($gatewayId, $operationId);
$rows = (new edge_gateway_operation_events_o())->getFieldsWhere([
'gateway_id' => $gatewayId,
'operation_id' => $operationId,
], ['id']);
$ids = array_map(static fn(array $row): int => (int)$row['id'], $rows);
sort($ids);
$ids = array_slice($ids, max(0, count($ids) - max(1, $limit)));
$events = [];
foreach ($ids as $id) {
$events[] = (new edge_gateway_operation_events_o())->select($id)->asArray();
}
return $events;
}
/**
* @throws Exception
*/
public function getActiveOperation(int $gatewayId, bool $includeEvents = true): ?array
{
$this->requireGateway($gatewayId);
$this->failTimedOutOperations($gatewayId);
$statement = db::getPDO()->prepare(
"SELECT id
FROM edge_gateway_operations
WHERE gateway_id = :gateway_id
AND deleted_at IS NULL
AND status IN ('PENDING', 'IN_PROGRESS')
ORDER BY FIELD(status, 'IN_PROGRESS', 'PENDING'), id ASC
LIMIT 1"
);
$statement->execute([':gateway_id' => $gatewayId]);
$row = $statement->fetch();
if (!is_array($row) || empty($row['id'])) {
return null;
}
return $this->serializeOperation((new edge_gateway_operations_o())->select((int)$row['id']), $includeEvents);
}
/**
* @throws Exception
*/
public function buildRecentOperationsSummary(int $gatewayId): array
{
$operations = $this->listOperations($gatewayId, 25, false);
$summary = [
'total' => count($operations),
'pending' => 0,
'in_progress' => 0,
'completed' => 0,
'failed' => 0,
'latest_completed_at' => null,
'latest_failed_at' => null,
'latest_type' => $operations[0]['type'] ?? null,
'latest_status' => $operations[0]['status'] ?? null,
];
foreach ($operations as $operation) {
$status = (string)($operation['status'] ?? self::STATUS_PENDING);
if ($status === self::STATUS_PENDING) {
$summary['pending'] += 1;
} elseif ($status === self::STATUS_IN_PROGRESS) {
$summary['in_progress'] += 1;
} elseif ($status === self::STATUS_COMPLETED) {
$summary['completed'] += 1;
$summary['latest_completed_at'] ??= $operation['completed_at'] ?? null;
} elseif ($status === self::STATUS_FAILED) {
$summary['failed'] += 1;
$summary['latest_failed_at'] ??= $operation['completed_at'] ?? null;
}
}
return $summary;
}
/**
* @throws Exception
*/
public function queueOperation(int $gatewayId, string $type, array $request = [], ?int $requestedBy = null): array
{
$gateway = $this->requireGateway($gatewayId);
$type = self::normalizeOperationType($type);
$request = $this->validateOperationRequest($gateway, $type, $request);
if ($this->getActiveOperation($gatewayId) !== null) {
throw new edge_gateway_operation_exception(
'Another gateway operation is already active',
self::ERROR_CONFLICT,
409
);
}
if ($type === self::TYPE_DISCOVERY) {
$gateway->discovery_status->set('PENDING');
} elseif ($type === self::TYPE_UPDATE) {
$targetVersion = trim((string)($request['target_version'] ?? ''));
if ($targetVersion !== '') {
$gateway->target_version->set($targetVersion);
}
}
$summary = [
'label' => match ($type) {
self::TYPE_DISCOVERY => 'Discovery queued',
self::TYPE_UPDATE => 'Update queued',
self::TYPE_UNINSTALL => 'Uninstall queued',
},
'progress' => 0,
'retryable' => true,
];
$operationId = (new edge_gateway_operations_o())->add_object([
'gateway_id' => $gatewayId,
'type' => $type,
'operation_type' => $type,
'status' => self::STATUS_PENDING,
'request_json' => $request,
'summary_json' => $summary,
'result_json' => [],
'error_code' => null,
'error_message' => null,
'correlation_id' => bin2hex(random_bytes(16)),
'agent_instance_id' => null,
'lease_expires_at' => null,
'last_progress_at' => null,
'attempt_count' => 0,
'requested_by' => $requestedBy,
'requested_at' => $this->now(),
'started_at' => null,
'completed_at' => null,
]);
$this->appendEventRecord(
$gatewayId,
$operationId,
self::LEVEL_INFO,
'OPERATION_QUEUED',
'Operation queued for gateway execution',
['type' => $type, 'request' => $request]
);
$this->manager()->logGatewayAudit(
$gatewayId,
(int)$gateway->department_id->value(),
'GATEWAY_OPERATION_QUEUED',
$requestedBy,
['operation_id' => $operationId, 'type' => $type]
);
return $this->serializeOperation((new edge_gateway_operations_o())->select($operationId), true);
}
/**
* @throws Exception
*/
public function queueDiscoveryOperation(int $gatewayId, ?int $requestedBy = null): array
{
return $this->queueOperation($gatewayId, self::TYPE_DISCOVERY, [], $requestedBy);
}
/**
* @throws Exception
*/
public function rotateCredentials(int $gatewayId, ?int $userId = null): array
{
return $this->manager()->rotateGatewayCredentials($gatewayId, $userId);
}
/**
* @throws Exception
*/
public function claimNextOperation(
int $gatewayId,
string $plainToken,
int $waitSeconds = edge_gateway_manager::COMMAND_POLL_TIMEOUT_SECONDS,
?string $agentInstanceId = null
): ?array {
try {
$this->manager()->authenticateGateway($gatewayId, $plainToken);
} catch (Exception) {
throw new edge_gateway_operation_exception('Invalid edge gateway token', self::ERROR_INVALID_TOKEN, 401);
}
$deadline = microtime(true) + max(0, $waitSeconds);
do {
$this->failTimedOutOperations($gatewayId);
$operation = $this->claimPendingOperation($gatewayId, $this->normalizeAgentInstanceId($agentInstanceId, $gatewayId));
if ($operation !== null) {
return $operation;
}
if (microtime(true) >= $deadline) {
break;
}
usleep(self::POLL_INTERVAL_MICROSECONDS);
} while (true);
return null;
}
/**
* @throws Exception
*/
public function appendAgentOperationEvent(int $gatewayId, int $operationId, string $plainToken, array $payload): array
{
try {
$this->manager()->authenticateGateway($gatewayId, $plainToken);
} catch (Exception) {
throw new edge_gateway_operation_exception('Invalid edge gateway token', self::ERROR_INVALID_TOKEN, 401);
}
$operation = $this->requireOperation($gatewayId, $operationId);
if (in_array((string)$operation->status->value(), [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) {
return $this->serializeOperation($operation, true);
}
$level = strtoupper(trim((string)($payload['level'] ?? self::LEVEL_INFO)));
if (!in_array($level, [self::LEVEL_INFO, self::LEVEL_WARNING, self::LEVEL_ERROR], true)) {
$level = self::LEVEL_INFO;
}
$message = trim((string)($payload['message'] ?? 'Operation event received'));
if ($message === '') {
$message = 'Operation event received';
}
$code = isset($payload['code']) ? trim((string)$payload['code']) : null;
$context = isset($payload['context']) && is_array($payload['context']) ? (array)$payload['context'] : [];
$this->appendEventRecord($gatewayId, $operationId, $level, $code, $message, $context);
$summary = (array)($operation->summary_json->value() ?? []);
$summary['last_event_at'] = $this->now();
$summary['last_event_message'] = $message;
if (isset($context['progress'])) {
$summary['progress'] = max(0, min(100, (int)$context['progress']));
}
if (isset($context['label']) && trim((string)$context['label']) !== '') {
$summary['label'] = trim((string)$context['label']);
}
$operation->summary_json->set($summary);
$this->refreshOperationLease($operation);
return $this->serializeOperation($operation, true);
}
/**
* @throws Exception
*/
public function completeAgentOperation(int $gatewayId, int $operationId, string $plainToken, array $payload): array
{
try {
$this->manager()->authenticateGateway($gatewayId, $plainToken);
} catch (Exception) {
throw new edge_gateway_operation_exception('Invalid edge gateway token', self::ERROR_INVALID_TOKEN, 401);
}
$operation = $this->requireOperation($gatewayId, $operationId);
if (in_array((string)$operation->status->value(), [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) {
return $this->serializeOperation($operation, true);
}
$ok = (bool)($payload['ok'] ?? false);
$result = isset($payload['result']) && is_array($payload['result']) ? (array)$payload['result'] : [];
$errorMessage = trim((string)($payload['error_message'] ?? $payload['error'] ?? ''));
$errorCode = trim((string)($payload['error_code'] ?? ''));
if (!$ok && $errorCode === '') {
$errorCode = $this->classifyCompletionError($gatewayId, $errorMessage);
}
if (!$ok && $errorMessage === '') {
$errorMessage = 'Gateway operation failed';
}
$operation->status->set($ok ? self::STATUS_COMPLETED : self::STATUS_FAILED);
$operation->result_json->set($result);
$operation->error_code->set($ok ? null : $errorCode);
$operation->error_message->set($ok ? null : $errorMessage);
$operation->completed_at->set($this->now());
$operation->lease_expires_at->set(null);
$operation->last_progress_at->set($this->now());
$summary = (array)($operation->summary_json->value() ?? []);
$summary['label'] = $ok ? 'Completed' : 'Failed';
$summary['progress'] = 100;
$summary['retryable'] = !$ok && $errorCode !== self::ERROR_UNSUPPORTED_VERSION;
$operation->summary_json->set($summary);
$this->appendEventRecord(
$gatewayId,
$operationId,
$ok ? self::LEVEL_INFO : self::LEVEL_ERROR,
$ok ? 'OPERATION_COMPLETED' : $errorCode,
$ok ? 'Operation completed successfully' : $errorMessage,
$result
);
$this->applyCompletionSideEffects($gatewayId, $operation, $ok, $result, $errorCode, $errorMessage);
return $this->serializeOperation($operation, true);
}
private static function normalizeOperationType(string $type): string
{
$normalized = strtoupper(trim($type));
if (in_array($normalized, [self::TYPE_DISCOVERY, self::TYPE_UPDATE, self::TYPE_UNINSTALL], true)) {
return $normalized;
}
throw new edge_gateway_operation_exception(
'Unsupported gateway operation type',
self::ERROR_VALIDATION,
422
);
}
/**
* @throws Exception
*/
private function validateOperationRequest(edge_gateways_o $gateway, string $type, array $request): array
{
if ($type === self::TYPE_DISCOVERY) {
return $request;
}
if ($type === self::TYPE_UPDATE) {
$targetVersion = trim((string)($request['target_version'] ?? ''));
if ($targetVersion === '') {
throw new edge_gateway_operation_exception(
'Update operations require target_version',
self::ERROR_VALIDATION,
422
);
}
$releaseChannel = trim((string)($request['release_channel'] ?? $gateway->release_channel->value() ?? edge_gateway_manager::DEFAULT_RELEASE_CHANNEL));
if ($releaseChannel === '') {
$releaseChannel = edge_gateway_manager::DEFAULT_RELEASE_CHANNEL;
}
return array_merge(
$this->manager()->buildUpdateOperationRequest($targetVersion, $releaseChannel),
$request,
[
'target_version' => $targetVersion,
'release_channel' => $releaseChannel,
]
);
}
if ($type === self::TYPE_UNINSTALL) {
return array_merge([
'service_name' => edge_gateway_manager::DEFAULT_AGENT_SERVICE_NAME,
'install_dir' => '/opt/truckwash-edge-agent',
], $request);
}
return $request;
}
/**
* @throws Exception
*/
private function claimPendingOperation(int $gatewayId, string $agentInstanceId): ?array
{
$pdo = db::getPDO();
$pdo->beginTransaction();
try {
$statement = $pdo->prepare(
"SELECT id
FROM edge_gateway_operations
WHERE gateway_id = :gateway_id
AND deleted_at IS NULL
AND status = 'PENDING'
ORDER BY id ASC
LIMIT 1
FOR UPDATE"
);
$statement->execute([':gateway_id' => $gatewayId]);
$row = $statement->fetch();
if (!is_array($row) || empty($row['id'])) {
$pdo->commit();
return null;
}
$operation = (new edge_gateway_operations_o())->select((int)$row['id']);
$operation->status->set(self::STATUS_IN_PROGRESS);
$operation->started_at->set($this->now());
$operation->agent_instance_id->set($agentInstanceId);
$operation->last_progress_at->set($this->now());
$operation->lease_expires_at->set($this->leaseExpiry());
$operation->attempt_count->set(((int)($operation->attempt_count->value() ?? 0)) + 1);
$summary = (array)($operation->summary_json->value() ?? []);
$summary['label'] = 'Gateway is processing the operation';
$summary['progress'] = max(5, (int)($summary['progress'] ?? 0));
$summary['claimed_by'] = $agentInstanceId;
$operation->summary_json->set($summary);
$pdo->commit();
$this->appendEventRecord(
$gatewayId,
(int)$operation->id,
self::LEVEL_INFO,
'OPERATION_STARTED',
'Gateway started processing the operation',
[
'type' => (string)$operation->type->value(),
'agent_instance_id' => $agentInstanceId,
'attempt_count' => (int)($operation->attempt_count->value() ?? 1),
]
);
return $this->serializeOperation($operation, true);
} catch (\Throwable $throwable) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $throwable;
}
}
/**
* @throws Exception
*/
private function failTimedOutOperations(int $gatewayId): void
{
$rows = (new edge_gateway_operations_o())->getFieldsWhere([
'gateway_id' => $gatewayId,
'status' => self::STATUS_IN_PROGRESS,
'deleted_at' => null,
], ['id']);
$now = time();
foreach ($rows as $row) {
$operation = (new edge_gateway_operations_o())->select((int)$row['id']);
$startedAt = $operation->started_at->value() === null ? null : strtotime((string)$operation->started_at->value());
$leaseExpiresAt = $operation->lease_expires_at->value() === null ? null : strtotime((string)$operation->lease_expires_at->value());
$timedOut = $startedAt !== false
&& $startedAt !== null
&& ($now - $startedAt) >= self::OPERATION_TIMEOUT_SECONDS;
$leaseExpired = $leaseExpiresAt !== false
&& $leaseExpiresAt !== null
&& $leaseExpiresAt <= $now;
if (!$timedOut && !$leaseExpired) {
continue;
}
$errorMessage = $leaseExpired
? 'Gateway stopped reporting operation progress before the lease expired'
: 'Gateway operation timed out';
$summaryLabel = $leaseExpired ? 'Lease expired' : 'Timed out';
$operation->status->set(self::STATUS_FAILED);
$operation->completed_at->set($this->now());
$operation->error_code->set(self::ERROR_OPERATION_TIMEOUT);
$operation->error_message->set($errorMessage);
$operation->lease_expires_at->set(null);
$summary = (array)($operation->summary_json->value() ?? []);
$summary['label'] = $summaryLabel;
$summary['retryable'] = true;
$operation->summary_json->set($summary);
$this->appendEventRecord(
$gatewayId,
(int)$operation->id,
self::LEVEL_ERROR,
self::ERROR_OPERATION_TIMEOUT,
$errorMessage,
[
'agent_instance_id' => $operation->agent_instance_id->value(),
'last_progress_at' => $operation->last_progress_at->value(),
]
);
}
}
/**
* @throws Exception
*/
private function applyCompletionSideEffects(
int $gatewayId,
edge_gateway_operations_o $operation,
bool $ok,
array $result,
string $errorCode,
string $errorMessage
): void {
$gateway = $this->requireGateway($gatewayId);
$metadata = (array)($gateway->metadata_json->value() ?? []);
$type = (string)$operation->type->value();
$now = $this->now();
$metadata['last_operation'] = [
'id' => (int)$operation->id,
'type' => $type,
'status' => $ok ? self::STATUS_COMPLETED : self::STATUS_FAILED,
'completed_at' => $now,
'error_code' => $ok ? null : $errorCode,
];
if ($type === self::TYPE_DISCOVERY) {
if ($ok) {
$inventory = isset($result['inventory']) && is_array($result['inventory']) ? (array)$result['inventory'] : [];
$this->manager()->syncGatewayInventory($gatewayId, $inventory);
$gateway->discovery_status->set('READY');
$metadata['last_discovery_completed_at'] = $now;
$metadata['last_discovery_error'] = null;
} else {
$gateway->discovery_status->set('FAILED');
$metadata['last_discovery_error'] = [
'code' => $errorCode,
'message' => $errorMessage,
'at' => $now,
];
}
} elseif ($type === self::TYPE_UPDATE) {
if ($ok) {
$installedVersion = trim((string)($result['installed_version'] ?? $result['target_version'] ?? $operation->request_json->value()['target_version'] ?? ''));
if ($installedVersion !== '') {
$gateway->installed_version->set($installedVersion);
$gateway->target_version->set($installedVersion);
}
$metadata['last_update_completed_at'] = $now;
$metadata['last_update_error'] = null;
} else {
$metadata['last_update_error'] = [
'code' => $errorCode,
'message' => $errorMessage,
'at' => $now,
];
}
} elseif ($type === self::TYPE_UNINSTALL) {
if ($ok) {
$gateway->status->set(edge_gateway_manager::STATUS_OFFLINE);
$metadata['uninstalled_at'] = $now;
$metadata['uninstall_error'] = null;
} else {
$metadata['uninstall_error'] = [
'code' => $errorCode,
'message' => $errorMessage,
'at' => $now,
];
}
}
$gateway->metadata_json->set($metadata);
$this->manager()->logGatewayAudit(
$gatewayId,
(int)$gateway->department_id->value(),
$ok ? 'GATEWAY_OPERATION_COMPLETED' : 'GATEWAY_OPERATION_FAILED',
null,
[
'operation_id' => (int)$operation->id,
'type' => $type,
'status' => $ok ? self::STATUS_COMPLETED : self::STATUS_FAILED,
'error_code' => $ok ? null : $errorCode,
]
);
}
/**
* @throws Exception
*/
private function serializeOperation(edge_gateway_operations_o $operation, bool $includeEvents = true): array
{
$operationArray = $operation->asArray();
if ($includeEvents) {
$operationArray['events'] = $this->listOperationEvents(
(int)$operation->gateway_id->value(),
(int)$operation->id,
20
);
}
return $operationArray;
}
/**
* @throws Exception
*/
private function requireGateway(int $gatewayId): edge_gateways_o
{
$gateway = (new edge_gateways_o())->select($gatewayId);
if (!$gateway->exists() || $gateway->deleted_at->value() !== null) {
throw new Exception('Edge gateway not found');
}
return $gateway;
}
/**
* @throws Exception
*/
private function requireOperation(int $gatewayId, int $operationId): edge_gateway_operations_o
{
$operation = (new edge_gateway_operations_o())->select($operationId);
if (!$operation->exists() || $operation->deleted_at->value() !== null) {
throw new Exception('Edge gateway operation not found');
}
if ((int)$operation->gateway_id->value() !== $gatewayId) {
throw new Exception('Edge gateway operation does not belong to this gateway');
}
return $operation;
}
private function appendEventRecord(
int $gatewayId,
int $operationId,
string $level,
?string $code,
string $message,
array $context
): array {
$eventId = (new edge_gateway_operation_events_o())->add_object([
'operation_id' => $operationId,
'gateway_id' => $gatewayId,
'level' => $level,
'code' => $code,
'message' => $message,
'context_json' => $context,
]);
return (new edge_gateway_operation_events_o())->select($eventId)->asArray();
}
private function refreshOperationLease(edge_gateway_operations_o $operation): void
{
$operation->last_progress_at->set($this->now());
$operation->lease_expires_at->set($this->leaseExpiry());
}
/**
* @throws Exception
*/
private function classifyCompletionError(int $gatewayId, string $errorMessage): string
{
$gateway = $this->requireGateway($gatewayId);
$gatewayStatus = edge_gateway_manager::resolveGatewayStatus(
(string)$gateway->status->value(),
$gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value()
);
if ($gatewayStatus === edge_gateway_manager::STATUS_OFFLINE) {
return self::ERROR_OFFLINE;
}
if ($gatewayStatus === edge_gateway_manager::STATUS_DEGRADED) {
return self::ERROR_STALE_HEARTBEAT;
}
$normalizedMessage = strtolower(trim($errorMessage));
if (str_contains($normalizedMessage, 'version')) {
return self::ERROR_UNSUPPORTED_VERSION;
}
if (str_contains($normalizedMessage, 'validation')) {
return self::ERROR_VALIDATION;
}
return self::ERROR_VALIDATION;
}
private function manager(): edge_gateway_manager
{
return $this->manager ?? new edge_gateway_manager();
}
private function now(): string
{
return date('Y-m-d H:i:s');
}
private function leaseExpiry(): string
{
return date('Y-m-d H:i:s', time() + self::OPERATION_LEASE_SECONDS);
}
private function normalizeAgentInstanceId(?string $agentInstanceId, int $gatewayId): string
{
$candidate = trim((string)$agentInstanceId);
if ($candidate === '') {
return 'gateway-' . $gatewayId;
}
return substr($candidate, 0, 128);
}
}
@@ -0,0 +1,92 @@
<?php
namespace classes;
use Exception;
class edge_gateway_registry_service
{
public function __construct(private readonly ?edge_gateway_manager $manager = null)
{
edge_gateway_schema_bootstrap::ensureTables();
}
public function createInstallToken(int $departmentId, ?string $label, ?int $createdBy = null): array
{
return $this->manager()->createInstallToken($departmentId, $label, $createdBy);
}
/**
* @throws Exception
*/
public function verifyInstallToken(string $plainToken): array
{
return $this->manager()->verifyInstallToken($plainToken);
}
/**
* @throws Exception
*/
public function claimGateway(
string $token,
string $hostname,
?string $installedVersion = null,
array $metadata = []
): array {
$payload = $this->manager()->claimGateway($token, $hostname, $installedVersion, $metadata);
unset($payload['broker_url']);
return $payload;
}
/**
* @throws Exception
*/
public function recordHeartbeat(int $gatewayId, string $plainToken, array $payload): array
{
return $this->manager()->recordHeartbeat($gatewayId, $plainToken, $payload);
}
/**
* @throws Exception
*/
public function updateGatewayMetadata(int $gatewayId, array $payload, ?int $userId = null): array
{
return $this->manager()->updateGatewayMetadata($gatewayId, $payload, $userId);
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function setRelayBindings(int $gatewayId, array $bindings, ?int $userId = null): array
{
return $this->manager()->setRelayBindings($gatewayId, $bindings, $userId);
}
/**
* @throws Exception
*/
public function deleteGateway(int $gatewayId, ?int $userId = null): array
{
return $this->manager()->deleteGateway($gatewayId, $userId);
}
/**
* @throws Exception
*/
public function setDepartmentTransportMode(int $departmentId, string $transportMode, ?int $userId = null): array
{
return $this->manager()->setDepartmentTransportMode($departmentId, $transportMode, $userId);
}
public function getDepartmentTransportMode(int $departmentId): string
{
return $this->manager()->getDepartmentTransportMode($departmentId);
}
private function manager(): edge_gateway_manager
{
return $this->manager ?? new edge_gateway_manager();
}
}
@@ -89,6 +89,7 @@ class edge_gateway_schema_bootstrap
device_id VARCHAR(255) NOT NULL,
local_ip VARCHAR(64) NULL,
channel INT NOT NULL DEFAULT 0,
fallback_mode VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL',
binding_source VARCHAR(64) NOT NULL DEFAULT 'MANUAL',
approved_by INT NULL,
approved_at DATETIME NULL,
@@ -123,81 +124,47 @@ class edge_gateway_schema_bootstrap
INDEX idx_edge_gateway_command_type (command_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS edge_gateway_update_jobs (
"CREATE TABLE IF NOT EXISTS edge_gateway_operations (
id INT AUTO_INCREMENT PRIMARY KEY,
gateway_id INT NOT NULL,
command_job_id INT NULL,
target_version VARCHAR(64) NOT NULL,
release_channel VARCHAR(32) NOT NULL DEFAULT 'stable',
type VARCHAR(32) NOT NULL,
operation_type VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
request_json JSON NULL,
summary_json JSON NULL,
result_json JSON NULL,
error_code VARCHAR(128) NULL,
error_message TEXT NULL,
correlation_id VARCHAR(128) NOT NULL,
agent_instance_id VARCHAR(128) NULL,
lease_expires_at DATETIME NULL,
last_progress_at DATETIME NULL,
attempt_count INT NOT NULL DEFAULT 0,
requested_by INT NULL,
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME NULL,
completed_at DATETIME NULL,
delivery_json JSON NULL,
result_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_edge_gateway_update_gateway (gateway_id),
INDEX idx_edge_gateway_update_command (command_job_id),
INDEX idx_edge_gateway_update_status (status)
UNIQUE KEY uniq_edge_gateway_operation_correlation (correlation_id),
INDEX idx_edge_gateway_operations_gateway (gateway_id),
INDEX idx_edge_gateway_operations_status (status),
INDEX idx_edge_gateway_operations_type (type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS edge_gateway_shell_sessions (
"CREATE TABLE IF NOT EXISTS edge_gateway_operation_events (
id INT AUTO_INCREMENT PRIMARY KEY,
operation_id INT NOT NULL,
gateway_id INT NOT NULL,
reason TEXT NOT NULL,
approval_status VARCHAR(32) NOT NULL DEFAULT 'APPROVED',
session_token_hash VARCHAR(255) NOT NULL,
requested_by INT NULL,
approved_by INT NULL,
approved_at DATETIME NULL,
expires_at DATETIME NOT NULL,
opened_at DATETIME NULL,
closed_at DATETIME NULL,
transcript_text LONGTEXT NULL,
metadata_json JSON NULL,
level VARCHAR(16) NOT NULL DEFAULT 'INFO',
code VARCHAR(128) NULL,
message TEXT NOT NULL,
context_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_edge_gateway_shell_gateway (gateway_id),
INDEX idx_edge_gateway_shell_expires (expires_at),
INDEX idx_edge_gateway_shell_status (approval_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS edge_gateway_shell_action_jobs (
id INT AUTO_INCREMENT PRIMARY KEY,
gateway_id INT NOT NULL,
session_id INT NOT NULL,
action_type VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
payload_json JSON NULL,
delivery_json JSON NULL,
requested_by INT NULL,
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME NULL,
error_message TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_edge_gateway_shell_action_gateway (gateway_id),
INDEX idx_edge_gateway_shell_action_session (session_id),
INDEX idx_edge_gateway_shell_action_status (status),
INDEX idx_edge_gateway_shell_action_type (action_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS edge_gateway_shell_events (
id INT AUTO_INCREMENT PRIMARY KEY,
gateway_id INT NOT NULL,
session_id INT NOT NULL,
event_type VARCHAR(32) NOT NULL,
payload_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_edge_gateway_shell_event_gateway (gateway_id),
INDEX idx_edge_gateway_shell_event_session (session_id),
INDEX idx_edge_gateway_shell_event_type (event_type)
INDEX idx_edge_gateway_operation_events_operation (operation_id),
INDEX idx_edge_gateway_operation_events_gateway (gateway_id),
INDEX idx_edge_gateway_operation_events_level (level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS edge_gateway_audit_logs (
@@ -220,38 +187,58 @@ class edge_gateway_schema_bootstrap
$db->query($sql);
}
if (!self::tableHasColumn('edge_gateway_update_jobs', 'command_job_id')) {
$db->query(
"ALTER TABLE edge_gateway_update_jobs
ADD COLUMN command_job_id INT NULL AFTER gateway_id,
ADD INDEX idx_edge_gateway_update_command (command_job_id)"
);
}
self::ensureColumn('edge_gateway_command_jobs', 'delivery_json', 'JSON NULL AFTER response_json');
if (!self::tableHasColumn('edge_gateway_command_jobs', 'delivery_json')) {
$db->query(
"ALTER TABLE edge_gateway_command_jobs
ADD COLUMN delivery_json JSON NULL AFTER response_json"
);
}
self::ensureColumn('edge_gateway_relay_bindings', 'fallback_mode', "VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL' AFTER channel");
if (!self::tableHasColumn('edge_gateway_shell_action_jobs', 'delivery_json')) {
$db->query(
"ALTER TABLE edge_gateway_shell_action_jobs
ADD COLUMN delivery_json JSON NULL AFTER payload_json"
);
}
self::ensureColumn('edge_gateway_operations', 'type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id");
self::ensureColumn('edge_gateway_operations', 'operation_type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER type");
self::ensureColumn('edge_gateway_operations', 'summary_json', 'JSON NULL AFTER request_json');
self::ensureColumn('edge_gateway_operations', 'result_json', 'JSON NULL AFTER summary_json');
self::ensureColumn('edge_gateway_operations', 'error_code', 'VARCHAR(128) NULL AFTER result_json');
self::ensureColumn('edge_gateway_operations', 'error_message', 'TEXT NULL AFTER error_code');
self::ensureColumn('edge_gateway_operations', 'correlation_id', 'VARCHAR(128) NULL AFTER error_message');
self::ensureColumn('edge_gateway_operations', 'agent_instance_id', 'VARCHAR(128) NULL AFTER correlation_id');
self::ensureColumn('edge_gateway_operations', 'lease_expires_at', 'DATETIME NULL AFTER agent_instance_id');
self::ensureColumn('edge_gateway_operations', 'last_progress_at', 'DATETIME NULL AFTER lease_expires_at');
self::ensureColumn('edge_gateway_operations', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER last_progress_at');
self::ensureColumn('edge_gateway_operations', 'requested_by', 'INT NULL AFTER attempt_count');
self::ensureColumn('edge_gateway_operations', 'requested_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER requested_by');
self::ensureColumn('edge_gateway_operations', 'started_at', 'DATETIME NULL AFTER requested_at');
self::ensureColumn('edge_gateway_operations', 'completed_at', 'DATETIME NULL AFTER started_at');
self::ensureColumn('edge_gateway_operations', 'updated_at', 'TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
self::ensureColumn('edge_gateway_operations', 'deleted_at', 'TIMESTAMP NULL DEFAULT NULL AFTER updated_at');
if (!self::tableHasColumn('edge_gateway_update_jobs', 'delivery_json')) {
$db->query(
"ALTER TABLE edge_gateway_update_jobs
ADD COLUMN delivery_json JSON NULL AFTER completed_at"
);
}
self::ensureColumn('edge_gateway_operation_events', 'code', 'VARCHAR(128) NULL AFTER level');
self::ensureColumn('edge_gateway_operation_events', 'context_json', 'JSON NULL AFTER message');
self::ensureColumn('edge_gateway_audit_logs', 'actor_type', "VARCHAR(32) NOT NULL DEFAULT 'USER' AFTER actor_user_id");
self::ensureColumn('edge_gateway_audit_logs', 'severity', "VARCHAR(16) NOT NULL DEFAULT 'INFO' AFTER actor_type");
self::ensureColumn('edge_gateway_audit_logs', 'context_json', 'JSON NULL AFTER severity');
self::syncOperationTypeColumns();
self::$initialized = true;
}
private static function ensureColumn(string $table, string $column, string $definition): void
{
global $db;
if (self::tableHasColumn($table, $column)) {
return;
}
if (!preg_match('/^[A-Za-z0-9_]+$/', $table) || !preg_match('/^[A-Za-z0-9_]+$/', $column)) {
throw new \RuntimeException('Invalid schema bootstrap identifier');
}
$db->query(
"ALTER TABLE `$table`
ADD COLUMN `$column` $definition"
);
}
private static function tableHasColumn(string $table, string $column): bool
{
global $db;
@@ -275,4 +262,30 @@ class edge_gateway_schema_bootstrap
$row = $result->fetch_assoc();
return ((int)($row['c'] ?? 0)) > 0;
}
private static function syncOperationTypeColumns(): void
{
global $db;
if (!self::tableHasColumn('edge_gateway_operations', 'type')
|| !self::tableHasColumn('edge_gateway_operations', 'operation_type')) {
return;
}
$db->query(
"UPDATE edge_gateway_operations
SET type = operation_type
WHERE operation_type IS NOT NULL
AND operation_type <> ''
AND (type IS NULL OR type = '' OR type <> operation_type)"
);
$db->query(
"UPDATE edge_gateway_operations
SET operation_type = type
WHERE type IS NOT NULL
AND type <> ''
AND (operation_type IS NULL OR operation_type = '')"
);
}
}
@@ -0,0 +1,45 @@
<?php
namespace classes;
use Exception;
class edge_gateway_view_service
{
public function __construct(private readonly ?edge_gateway_manager $manager = null)
{
edge_gateway_schema_bootstrap::ensureTables();
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function listGateways(?int $departmentId = null, bool $includeDetail = true): array
{
return $this->manager()->listGateways($departmentId, $includeDetail);
}
/**
* @param array<int,array<string,mixed>> $gateways
* @return array<string,mixed>
* @throws Exception
*/
public function buildFleetUsageStatistics(?int $departmentId = null, array $gateways = []): array
{
return $this->manager()->buildFleetUsageStatistics($departmentId, $gateways);
}
/**
* @throws Exception
*/
public function getGateway(int $gatewayId): array
{
return $this->manager()->getGateway($gatewayId);
}
private function manager(): edge_gateway_manager
{
return $this->manager ?? new edge_gateway_manager();
}
}
+23
View File
@@ -39,6 +39,8 @@ use Psr\Http\Client\ClientExceptionInterface;
#[AllowDynamicProperties] class email implements email_i
{
public static array $fake_deliveries = [];
/**
* Configuration for the email service
* @var email_c
@@ -125,6 +127,17 @@ 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
{
if (self::isFakeDeliveryEnabled()) {
self::$fake_deliveries[] = [
'to' => $to,
'recipient_name' => $recipient_name,
'subject' => $subject,
'message' => $message,
'html' => $html,
];
return;
}
// Check if the email is blacklisted
$blacklisted_emails = [
'invoice.dk@freja.com', // TODO: Make this dynamic.
@@ -209,6 +222,16 @@ use Psr\Http\Client\ClientExceptionInterface;
$this->sendEmailMailerSend($to, $recipient_name, 'Betalingslink for bestilling #' . $order_id, '', $html);
}
public static function resetFakeDeliveries(): void
{
self::$fake_deliveries = [];
}
private static function isFakeDeliveryEnabled(): bool
{
return getenv('EMAIL_FAKE_MODE') === '1';
}
/**
* Send a booking confirmation email
* @throws MailerSendException
@@ -0,0 +1,201 @@
<?php
namespace classes;
use Throwable;
class order_bookings_counts_cache
{
public const PREFIX = 'order_bookings:counts:v1:';
/**
* Optional runtime adapter for tests.
*/
private static ?object $adapter = null;
public static function setAdapterForTests(?object $adapter): void
{
self::$adapter = $adapter;
}
public static function getTtl(): int
{
$raw = getenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL');
if ($raw === false || trim((string)$raw) === '') {
return 30;
}
return max(0, (int)$raw);
}
public static function buildKey(array $context): string
{
$normalized = self::normalizeValue($context);
$encoded = json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
$encoded = serialize($normalized);
}
return self::PREFIX . md5($encoded);
}
public static function getCounts(string $key): ?array
{
$raw = self::redisGet($key);
if ($raw === null || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return null;
}
return self::normalizeCounts($decoded);
}
public static function storeCounts(string $key, array $counts, ?int $ttl = null): void
{
$cacheTtl = $ttl ?? self::getTtl();
if ($cacheTtl <= 0) {
return;
}
$normalized = self::normalizeCounts($counts);
if ($normalized === null) {
return;
}
$encoded = json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
return;
}
self::redisSetEx($key, $encoded, $cacheTtl);
}
public static function clearAll(): void
{
self::clearPattern(self::PREFIX . '*');
}
private static function normalizeCounts(array $counts): ?array
{
if (!array_key_exists('past', $counts) || !array_key_exists('current', $counts) || !array_key_exists('future', $counts)) {
return null;
}
return [
'past' => max(0, (int)$counts['past']),
'current' => max(0, (int)$counts['current']),
'future' => max(0, (int)$counts['future']),
];
}
private static function normalizeValue(mixed $value): mixed
{
if (!is_array($value)) {
return self::normalizeScalar($value);
}
$normalized = array_map([self::class, 'normalizeValue'], $value);
if (array_is_list($normalized)) {
if (self::isScalarList($normalized)) {
sort($normalized);
}
return $normalized;
}
ksort($normalized);
return $normalized;
}
private static function normalizeScalar(mixed $value): mixed
{
if (!is_string($value)) {
return $value;
}
if (preg_match('/^-?\d+$/', $value) === 1) {
return (int)$value;
}
if (is_numeric($value)) {
return (float)$value;
}
return $value;
}
private static function isScalarList(array $value): bool
{
foreach ($value as $item) {
if (is_array($item) || is_object($item)) {
return false;
}
}
return true;
}
private static function clearPattern(string $pattern): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->clear_keys($pattern);
} catch (Throwable) {
// Cache invalidation must never break request flow.
}
}
private static function redisSetEx(string $key, string $value, int $ttl): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->setEx($key, $value, $ttl);
} catch (Throwable) {
// Best-effort cache write.
}
}
private static function redisGet(string $key): ?string
{
try {
$client = self::redisClient();
if ($client === null) {
return null;
}
$value = $client->get($key);
return is_string($value) ? $value : null;
} catch (Throwable) {
return null;
}
}
private static function redisClient(): ?object
{
if (self::$adapter !== null) {
return self::$adapter;
}
try {
if (defined('redis')) {
$instance = constant('redis');
if (is_object($instance)) {
return $instance;
}
}
return (new redis())->connect();
} catch (Throwable) {
return null;
}
}
}
@@ -0,0 +1,190 @@
<?php
namespace classes;
use Throwable;
class order_bookings_list_cache
{
public const PREFIX = 'order_bookings:list:v1:';
/**
* Optional runtime adapter for tests.
*/
private static ?object $adapter = null;
public static function setAdapterForTests(?object $adapter): void
{
self::$adapter = $adapter;
}
public static function getTtl(): int
{
$raw = getenv('ORDER_BOOKINGS_LIST_CACHE_TTL');
if ($raw === false || trim((string)$raw) === '') {
return 30;
}
return max(0, (int)$raw);
}
public static function buildKey(array $context): string
{
$normalized = self::normalizeValue($context);
$encoded = json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
$encoded = serialize($normalized);
}
return self::PREFIX . md5($encoded);
}
public static function getPayload(string $key): ?array
{
$raw = self::redisGet($key);
if ($raw === null || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return null;
}
if (!array_key_exists('success', $decoded) || !array_key_exists('data', $decoded)) {
return null;
}
$decoded['meta'] = isset($decoded['meta']) && is_array($decoded['meta']) ? $decoded['meta'] : [];
$decoded['includes'] = isset($decoded['includes']) && is_array($decoded['includes']) ? $decoded['includes'] : [];
return $decoded;
}
public static function storePayload(string $key, array $payload, ?int $ttl = null): void
{
$cacheTtl = $ttl ?? self::getTtl();
if ($cacheTtl <= 0) {
return;
}
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
return;
}
self::redisSetEx($key, $encoded, $cacheTtl);
}
public static function clearAll(): void
{
self::clearPattern(self::PREFIX . '*');
}
private static function normalizeValue(mixed $value): mixed
{
if (!is_array($value)) {
return self::normalizeScalar($value);
}
$normalized = array_map([self::class, 'normalizeValue'], $value);
if (array_is_list($normalized)) {
if (self::isScalarList($normalized)) {
sort($normalized);
}
return $normalized;
}
ksort($normalized);
return $normalized;
}
private static function normalizeScalar(mixed $value): mixed
{
if (!is_string($value)) {
return $value;
}
if (preg_match('/^-?\d+$/', $value) === 1) {
return (int)$value;
}
if (is_numeric($value)) {
return (float)$value;
}
return $value;
}
private static function isScalarList(array $value): bool
{
foreach ($value as $item) {
if (is_array($item) || is_object($item)) {
return false;
}
}
return true;
}
private static function clearPattern(string $pattern): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->clear_keys($pattern);
} catch (Throwable) {
// Cache invalidation must never break request flow.
}
}
private static function redisSetEx(string $key, string $value, int $ttl): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->setEx($key, $value, $ttl);
} catch (Throwable) {
// Best-effort cache write.
}
}
private static function redisGet(string $key): ?string
{
try {
$client = self::redisClient();
if ($client === null) {
return null;
}
$value = $client->get($key);
return is_string($value) ? $value : null;
} catch (Throwable) {
return null;
}
}
private static function redisClient(): ?object
{
if (self::$adapter !== null) {
return self::$adapter;
}
try {
if (defined('redis')) {
$instance = constant('redis');
if (is_object($instance)) {
return $instance;
}
}
return (new redis())->connect();
} catch (Throwable) {
return null;
}
}
}
+10
View File
@@ -89,6 +89,16 @@ class response implements response_i
return $this->data;
}
public function get_meta(): array
{
return $this->meta;
}
public function get_includes(): array
{
return $this->includes;
}
#[NoReturn] public function not_found(): void
{
$this->error('Not found', 404);
+14
View File
@@ -10,6 +10,7 @@ require_once WD . '/modules/stripe/endpoints/stripe_endpoint_prices.php';
require_once WD . '/modules/stripe/endpoints/stripe_endpoint_invoice.php';
require_once WD . '/modules/stripe/endpoints/stripe_endpoint_readers.php';
require_once WD . '/modules/stripe/endpoints/stripe_endpoint_payment_intents.php';
require_once WD . '/classes/stripe_fake_http_client.php';
// Require all helper classes
@@ -29,6 +30,7 @@ use stripe\endpoints\stripe_endpoint_prices;
use stripe\endpoints\stripe_endpoint_product;
use stripe\endpoints\stripe_endpoint_readers;
use stripe\stripe_c;
use Stripe\ApiRequestor;
use Stripe\StripeClient;
/**
@@ -104,6 +106,13 @@ class stripe implements stripe_i
{
// Get the stripe client
if (!isset($this->client)) {
if (self::isFakeModeEnabled()) {
ApiRequestor::setHttpClient(new stripe_fake_http_client());
$this->client = new StripeClient([
'api_key' => 'sk_test_fake'
]);
return $this->client;
}
// Require the module to be enabled
self::requireModuleEnabled();
// Require the secret key to be set
@@ -118,6 +127,11 @@ class stripe implements stripe_i
return $this->client;
}
private static function isFakeModeEnabled(): bool
{
return getenv('STRIPE_FAKE_MODE') === '1';
}
/**
* @inheritDoc
* @throws Exception
@@ -0,0 +1,268 @@
<?php
namespace classes;
use Stripe\HttpClient\ClientInterface;
class stripe_fake_http_client implements ClientInterface
{
private const DEFAULT_STATE = [
'next_customer' => 1,
'next_price' => 1,
'next_invoice' => 1,
'next_invoice_item' => 1,
'customers' => [],
'prices' => [],
'products' => [],
'invoice_items' => [],
'invoices' => [],
];
public static function resetStore(): void
{
self::writeStore(self::DEFAULT_STATE);
}
public static function setInvoiceState(string $invoiceId, array $attributes): void
{
$store = self::readStore();
$invoice = $store['invoices'][$invoiceId] ?? null;
if (!$invoice) {
return;
}
$store['invoices'][$invoiceId] = [
...$invoice,
...$attributes,
];
self::writeStore($store);
}
public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1')
{
$path = (string)parse_url((string)$absUrl, PHP_URL_PATH);
$store = self::readStore();
if ($method === 'post' && $path === '/v1/customers') {
$customerId = sprintf('cus_fake_%06d', (int)$store['next_customer']);
$store['next_customer']++;
$customer = [
'id' => $customerId,
'object' => 'customer',
'email' => (string)($params['email'] ?? ''),
];
$store['customers'][$customerId] = $customer;
self::writeStore($store);
return $this->jsonResponse($customer);
}
if ($method === 'get' && preg_match('#^/v1/customers/(?P<id>[^/]+)$#', $path, $matches)) {
$customer = $store['customers'][$matches['id']] ?? null;
if (!$customer) {
return $this->errorResponse(404, 'resource_missing', 'No such customer.');
}
return $this->jsonResponse($customer);
}
if ($method === 'get' && preg_match('#^/v1/products/(?P<id>[^/]+)$#', $path, $matches)) {
$product = $store['products'][$matches['id']] ?? null;
if (!$product) {
return $this->errorResponse(404, 'resource_missing', 'No such product.');
}
return $this->jsonResponse($product);
}
if ($method === 'post' && $path === '/v1/products') {
$productId = (string)($params['id'] ?? sprintf('prod_fake_%06d', count($store['products']) + 1));
$product = [
'id' => $productId,
'object' => 'product',
'name' => (string)($params['name'] ?? 'Fake Product'),
];
$store['products'][$productId] = $product;
self::writeStore($store);
return $this->jsonResponse($product);
}
if ($method === 'post' && $path === '/v1/prices') {
$priceId = sprintf('price_fake_%06d', (int)$store['next_price']);
$store['next_price']++;
$price = [
'id' => $priceId,
'object' => 'price',
'product' => (string)($params['product'] ?? ''),
'unit_amount' => (int)($params['unit_amount'] ?? 0),
'currency' => strtolower((string)($params['currency'] ?? 'dkk')),
];
$store['prices'][$priceId] = $price;
self::writeStore($store);
return $this->jsonResponse($price);
}
if ($method === 'post' && $path === '/v1/invoices') {
$invoiceId = sprintf('in_fake_%06d', (int)$store['next_invoice']);
$store['next_invoice']++;
$invoice = [
'id' => $invoiceId,
'object' => 'invoice',
'customer' => (string)($params['customer'] ?? ''),
'status' => 'draft',
'paid' => false,
'amount_due' => 0,
'amount_paid' => 0,
'collection_method' => (string)($params['collection_method'] ?? 'send_invoice'),
'hosted_invoice_url' => sprintf('https://stripe.test/invoices/%s', $invoiceId),
'metadata' => is_array($params['metadata'] ?? null) ? $params['metadata'] : [],
'lines' => [],
];
$store['invoices'][$invoiceId] = $invoice;
self::writeStore($store);
return $this->jsonResponse($invoice);
}
if ($method === 'post' && $path === '/v1/invoiceitems') {
$invoiceId = (string)($params['invoice'] ?? '');
$invoice = $store['invoices'][$invoiceId] ?? null;
if (!$invoice) {
return $this->errorResponse(404, 'resource_missing', 'No such invoice.');
}
$invoiceItemId = sprintf('ii_fake_%06d', (int)$store['next_invoice_item']);
$store['next_invoice_item']++;
$priceId = (string)($params['price'] ?? '');
$price = $store['prices'][$priceId] ?? ['unit_amount' => 0];
$invoiceItem = [
'id' => $invoiceItemId,
'object' => 'invoiceitem',
'invoice' => $invoiceId,
'customer' => (string)($params['customer'] ?? ''),
'price' => $priceId,
'amount' => (int)($price['unit_amount'] ?? 0),
];
$store['invoice_items'][$invoiceItemId] = $invoiceItem;
$store['invoices'][$invoiceId]['lines'][] = $invoiceItemId;
$store['invoices'][$invoiceId]['amount_due'] += (int)($price['unit_amount'] ?? 0);
self::writeStore($store);
return $this->jsonResponse($invoiceItem);
}
if ($method === 'post' && preg_match('#^/v1/invoices/(?P<id>[^/]+)/finalize$#', $path, $matches)) {
$invoiceId = $matches['id'];
$invoice = $store['invoices'][$invoiceId] ?? null;
if (!$invoice) {
return $this->errorResponse(404, 'resource_missing', 'No such invoice.');
}
$invoice['status'] = 'open';
$store['invoices'][$invoiceId] = $invoice;
self::writeStore($store);
return $this->jsonResponse($invoice);
}
if ($method === 'post' && preg_match('#^/v1/invoices/(?P<id>[^/]+)/void$#', $path, $matches)) {
$invoiceId = $matches['id'];
$invoice = $store['invoices'][$invoiceId] ?? null;
if (!$invoice) {
return $this->errorResponse(404, 'resource_missing', 'No such invoice.');
}
$invoice['status'] = 'void';
$invoice['paid'] = false;
$store['invoices'][$invoiceId] = $invoice;
self::writeStore($store);
return $this->jsonResponse($invoice);
}
if ($method === 'get' && preg_match('#^/v1/invoices/(?P<id>[^/]+)$#', $path, $matches)) {
$invoice = $store['invoices'][$matches['id']] ?? null;
if (!$invoice) {
return $this->errorResponse(404, 'resource_missing', 'No such invoice.');
}
return $this->jsonResponse($invoice);
}
return $this->errorResponse(404, 'resource_missing', 'Unsupported fake Stripe request: ' . $method . ' ' . $path);
}
private function jsonResponse(array $payload, int $status = 200): array
{
return [
json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$status,
[
'Content-Type' => 'application/json',
'Request-Id' => 'req_fake_stripe',
],
];
}
private function errorResponse(int $status, string $code, string $message): array
{
return [
json_encode([
'error' => [
'type' => 'invalid_request_error',
'code' => $code,
'message' => $message,
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$status,
[
'Content-Type' => 'application/json',
'Request-Id' => 'req_fake_stripe_error',
],
];
}
private static function readStore(): array
{
$path = self::storePath();
if (!is_file($path)) {
return self::DEFAULT_STATE;
}
$json = file_get_contents($path);
if ($json === false || trim($json) === '') {
return self::DEFAULT_STATE;
}
$decoded = json_decode($json, true);
if (!is_array($decoded)) {
return self::DEFAULT_STATE;
}
return [
...self::DEFAULT_STATE,
...$decoded,
];
}
private static function writeStore(array $store): void
{
file_put_contents(
self::storePath(),
json_encode($store, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)
);
}
private static function storePath(): string
{
$path = trim((string)getenv('STRIPE_FAKE_STORE_PATH'));
if ($path !== '') {
return $path;
}
return sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'truckwash-stripe-fake-store.json';
}
}
@@ -0,0 +1,258 @@
<?php
namespace classes;
use DateTime;
use DateTimeZone;
use Exception;
final class workfeed_shift_time_resolver
{
private const MAX_UNAPPROVED_EXTENSION_SECONDS = 6 * 3600;
private const MAX_UNAPPROVED_SHIFT_SPAN_SECONDS = 24 * 3600;
/**
* @return array{actualStart:DateTime,scheduledEnd:DateTime,actualEnd:DateTime,hasApproval:bool}|null
*/
public static function resolveShiftTiming(mixed $record_value): ?array
{
$record = self::normalizeRecord($record_value);
if ($record === []) {
return null;
}
$actual_start = self::firstDateTimeFromPaths($record, [
'actualStart',
'actualStartTime',
'clockIn',
'clockInTime',
'start',
'startTime',
'from',
'approval.originalStart',
]);
$scheduled_end = self::firstDateTimeFromPaths($record, [
'approval.originalEnd',
'end',
'endTime',
'to',
]);
$actual_only_end = self::firstDateTimeFromPaths($record, [
'actualEnd',
'actualEndTime',
'clockOut',
'clockOutTime',
]);
$current_end = self::firstDateTimeFromPaths($record, [
'end',
'endTime',
'to',
]);
$saved_actual_end = $actual_only_end ?? $current_end;
if ($actual_start === null || $scheduled_end === null || $saved_actual_end === null) {
return null;
}
$actual_end = self::resolveActualEnd($record, $actual_start, $scheduled_end, $saved_actual_end, $actual_only_end);
if ($actual_end->getTimestamp() <= $actual_start->getTimestamp()) {
return null;
}
return [
'actualStart' => $actual_start,
'scheduledEnd' => $scheduled_end,
'actualEnd' => $actual_end,
'hasApproval' => self::hasShiftApproval($record),
];
}
public static function calculateOvertimeHoursInRange(mixed $record_value, DateTime $range_start, DateTime $range_end_exclusive): float
{
$timing = self::resolveShiftTiming($record_value);
if ($timing === null) {
return 0.0;
}
$scheduled_end_ts = $timing['scheduledEnd']->getTimestamp();
$actual_end_ts = $timing['actualEnd']->getTimestamp();
if ($actual_end_ts <= $scheduled_end_ts) {
return 0.0;
}
$overtime_start_ts = max($scheduled_end_ts, $range_start->getTimestamp());
$overtime_end_ts = min($actual_end_ts, $range_end_exclusive->getTimestamp());
if ($overtime_end_ts <= $overtime_start_ts) {
return 0.0;
}
return round(($overtime_end_ts - $overtime_start_ts) / 3600, 2);
}
/**
* @return array<string,mixed>
*/
private static function normalizeRecord(mixed $record): array
{
if (is_array($record)) {
return $record;
}
if (is_object($record)) {
return get_object_vars($record);
}
return [];
}
private static function getNestedRecordValue(array $record, string $path): mixed
{
$segments = explode('.', $path);
$current = $record;
foreach ($segments as $segment) {
if (is_array($current)) {
if (!array_key_exists($segment, $current)) {
return null;
}
$current = $current[$segment];
continue;
}
if (is_object($current)) {
if (!property_exists($current, $segment)) {
return null;
}
$current = $current->$segment;
continue;
}
return null;
}
return $current;
}
/**
* @param array<int,string> $paths
*/
private static function firstDateTimeFromPaths(array $record, array $paths): ?DateTime
{
foreach ($paths as $path) {
$parsed = self::parseDateTimeValue(self::getNestedRecordValue($record, $path));
if ($parsed !== null) {
return $parsed;
}
}
return null;
}
private static function parseDateTimeValue(mixed $value): ?DateTime
{
if (is_string($value)) {
$normalized = trim($value);
if ($normalized === '') {
return null;
}
try {
return new DateTime($normalized);
} catch (Exception) {
if (!is_numeric($normalized)) {
return null;
}
$value = (float)$normalized;
}
}
if (is_int($value) || is_float($value)) {
if (!is_finite((float)$value)) {
return null;
}
$timestamp = (float)$value;
if ($timestamp > 9999999999) {
$timestamp /= 1000;
}
try {
$date = new DateTime('@' . (string)(int)round($timestamp));
$date->setTimezone(new DateTimeZone('UTC'));
return $date;
} catch (Exception) {
return null;
}
}
$record = self::normalizeRecord($value);
foreach (['seconds', '_seconds', 'epochSeconds', 'timestamp'] as $key) {
if (!array_key_exists($key, $record)) {
continue;
}
$parsed = self::parseDateTimeValue($record[$key]);
if ($parsed !== null) {
return $parsed;
}
}
return null;
}
private static function hasShiftApproval(array $record): bool
{
if (!array_key_exists('approval', $record)) {
return false;
}
$approval = $record['approval'];
if ($approval === null) {
return false;
}
if (is_array($approval)) {
return $approval !== [];
}
if (is_object($approval)) {
return get_object_vars($approval) !== [];
}
return true;
}
private static function resolveActualEnd(
array $record,
DateTime $actual_start,
DateTime $scheduled_end,
DateTime $saved_actual_end,
?DateTime $actual_only_end
): DateTime {
if (self::hasShiftApproval($record) || $actual_only_end !== null) {
return $saved_actual_end;
}
$update_time = self::parseDateTimeValue($record['updateTime'] ?? null);
if ($update_time === null) {
return $saved_actual_end;
}
$shift_start_ts = $actual_start->getTimestamp();
$scheduled_end_ts = $scheduled_end->getTimestamp();
$saved_actual_end_ts = $saved_actual_end->getTimestamp();
$update_ts = $update_time->getTimestamp();
$fallback_base_ts = max($saved_actual_end_ts, $scheduled_end_ts);
if ($update_ts <= $fallback_base_ts) {
return $saved_actual_end;
}
// Only use updateTime as an overtime hint when no explicit actual end was saved.
if (($update_ts - $scheduled_end_ts) > self::MAX_UNAPPROVED_EXTENSION_SECONDS) {
return $saved_actual_end;
}
if (($update_ts - $shift_start_ts) > self::MAX_UNAPPROVED_SHIFT_SPAN_SECONDS) {
return $saved_actual_end;
}
return $update_time;
}
}
@@ -0,0 +1,28 @@
<?php
namespace config;
use traits\module_config_variable;
class economic_transaction_draft_customer_number_c
{
use module_config_variable;
/**
* @throws \Exception
*/
public function __construct()
{
self::setupConfigVariable(
'economic',
'transactionDraftCustomerNumber',
'int',
false,
null,
'Customer number used for transaction drafts that must not export to e-conomic',
'99999999',
false,
null
);
}
}
@@ -4,12 +4,14 @@ require_once WD . '/modules/economic/config/economic_payment_terms_c.php';
require_once WD . '/modules/economic/config/economic_admin_fee_monthly_c.php';
require_once WD . '/modules/economic/config/economic_admin_fee_order_c.php';
require_once WD . '/modules/economic/config/economic_fee_product_id_c.php';
require_once WD . '/modules/economic/config/economic_transaction_draft_customer_number_c.php';
use config\economic_invoice_layout_c;
use config\economic_payment_terms_c;
use config\economic_admin_fee_monthly_c;
use config\economic_admin_fee_order_c;
use config\economic_fee_product_id_c;
use config\economic_transaction_draft_customer_number_c;
use traits\module_config_t;
class economic_c
@@ -21,6 +23,7 @@ class economic_c
public economic_admin_fee_monthly_c $admin_fee_monthly;
public economic_admin_fee_order_c $admin_fee_order;
public economic_fee_product_id_c $fee_product_id;
public economic_transaction_draft_customer_number_c $transaction_draft_customer_number;
public function __construct()
{
@@ -30,12 +33,14 @@ class economic_c
economic_payment_terms_c::class,
economic_admin_fee_monthly_c::class,
economic_admin_fee_order_c::class,
economic_fee_product_id_c::class
economic_fee_product_id_c::class,
economic_transaction_draft_customer_number_c::class,
]);
$this->invoice_layout = new economic_invoice_layout_c();
$this->payment_terms = new economic_payment_terms_c();
$this->admin_fee_monthly = new economic_admin_fee_monthly_c();
$this->admin_fee_order = new economic_admin_fee_order_c();
$this->fee_product_id = new economic_fee_product_id_c();
$this->transaction_draft_customer_number = new economic_transaction_draft_customer_number_c();
}
}
@@ -26,6 +26,14 @@ class stripe_endpoint_invoice
return self::getClient()->invoices->retrieve($id);
}
/**
* @throws ApiErrorException
*/
public function void(string $id): Invoice
{
return self::getClient()->invoices->voidInvoice($id);
}
/**
* @throws ApiErrorException
* @throws Exception
@@ -53,13 +61,47 @@ class stripe_endpoint_invoice
return $this->create(
$line_items,
$stripe_customer_id,
[
'order_id' => $order_id,
'customer_id' => $stripe_customer_id
]
$this->buildOrderMetadata($order, $stripe_customer_id)
);
}
private function buildOrderMetadata(orders_o $order, string $stripe_customer_id): array
{
$metadata = [
'order_id' => (string)$order->id,
'customer_id' => (string)$order->customer_id->value(),
'stripe_customer_id' => $stripe_customer_id,
'department_id' => (string)$order->department_id->value(),
];
$reference = trim((string)$order->reference->value());
if ($reference !== '') {
$metadata['reference'] = $reference;
}
$po = trim((string)$order->po->value());
if ($po !== '') {
$metadata['po'] = $po;
}
$reg1 = trim((string)$order->reg_1->value());
if ($reg1 !== '') {
$metadata['reg_1'] = $reg1;
}
$reg2 = trim((string)$order->reg_2->value());
if ($reg2 !== '') {
$metadata['reg_2'] = $reg2;
}
$reg3 = trim((string)$order->reg_3->value());
if ($reg3 !== '') {
$metadata['reg_3'] = $reg3;
}
return $metadata;
}
/**
* Create a new payment link
* @param stripe_line_items $line_items
@@ -426,6 +426,7 @@ class collected_order_invoices_o extends db
if (empty($this->customer_number->value())) {
throw new Exception('Customer number is not set');
}
(new economic())->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
if (!$ignore_closed) {
// Require the invoice collection to be open
self::requireOpen();
@@ -479,6 +480,7 @@ class collected_order_invoices_o extends db
{
// Require the invoice collection to be selected
self::requireSelected();
(new economic())->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
// Check if the invoice draft already exists
if (self::isDraftExisting() || self::isBooked()) {
throw new Exception('Invoice draft already exists, or invoice collection is already booked');
@@ -0,0 +1,58 @@
<?php
namespace objects;
use classes\db;
use classes\edge_gateway_schema_bootstrap;
use classes\object_property;
use traits\db_object_t;
class edge_gateway_operation_events_o extends db
{
use db_object_t;
public object_property $operation_id;
public object_property $gateway_id;
public object_property $level;
public object_property $code;
public object_property $message;
public object_property $context_json;
public object_property $created_at;
public function structure(): void
{
edge_gateway_schema_bootstrap::ensureTables();
$this->setTable('edge_gateway_operation_events');
}
public function getObjectProperties(): void
{
$this->operation_id = new object_property($this->table, $this->id, 'operation_id', 'int', false);
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
$this->level = new object_property($this->table, $this->id, 'level', 'string', false);
$this->code = new object_property($this->table, $this->id, 'code', 'string', false);
$this->message = new object_property($this->table, $this->id, 'message', 'text', false);
$this->context_json = new object_property($this->table, $this->id, 'context_json', 'json', false);
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
}
public function objectChanged(): void
{
}
public function asArray(): array
{
$this->requireSelected();
return [
'id' => (int)$this->id,
'operation_id' => (int)$this->operation_id->value(),
'gateway_id' => (int)$this->gateway_id->value(),
'level' => (string)$this->level->value(),
'code' => $this->code->value() === null ? null : (string)$this->code->value(),
'message' => (string)$this->message->value(),
'context' => (array)($this->context_json->value() ?? []),
'created_at' => (string)$this->created_at->value(),
];
}
}
@@ -7,21 +7,28 @@ use classes\edge_gateway_schema_bootstrap;
use classes\object_property;
use traits\db_object_t;
class edge_gateway_update_jobs_o extends db
class edge_gateway_operations_o extends db
{
use db_object_t;
public object_property $gateway_id;
public object_property $command_job_id;
public object_property $target_version;
public object_property $release_channel;
public object_property $type;
public object_property $operation_type;
public object_property $status;
public object_property $request_json;
public object_property $summary_json;
public object_property $result_json;
public object_property $error_code;
public object_property $error_message;
public object_property $correlation_id;
public object_property $agent_instance_id;
public object_property $lease_expires_at;
public object_property $last_progress_at;
public object_property $attempt_count;
public object_property $requested_by;
public object_property $requested_at;
public object_property $started_at;
public object_property $completed_at;
public object_property $delivery_json;
public object_property $result_json;
public object_property $created_at;
public object_property $updated_at;
public object_property $deleted_at;
@@ -29,22 +36,29 @@ class edge_gateway_update_jobs_o extends db
public function structure(): void
{
edge_gateway_schema_bootstrap::ensureTables();
$this->setTable('edge_gateway_update_jobs');
$this->setTable('edge_gateway_operations');
}
public function getObjectProperties(): void
{
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
$this->command_job_id = new object_property($this->table, $this->id, 'command_job_id', 'int', false);
$this->target_version = new object_property($this->table, $this->id, 'target_version', 'string', false);
$this->release_channel = new object_property($this->table, $this->id, 'release_channel', 'string', false);
$this->type = new object_property($this->table, $this->id, 'type', 'string', false);
$this->operation_type = new object_property($this->table, $this->id, 'operation_type', 'string', false);
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
$this->request_json = new object_property($this->table, $this->id, 'request_json', 'json', false);
$this->summary_json = new object_property($this->table, $this->id, 'summary_json', 'json', false);
$this->result_json = new object_property($this->table, $this->id, 'result_json', 'json', false);
$this->error_code = new object_property($this->table, $this->id, 'error_code', 'string', false);
$this->error_message = new object_property($this->table, $this->id, 'error_message', 'text', false);
$this->correlation_id = new object_property($this->table, $this->id, 'correlation_id', 'string', false);
$this->agent_instance_id = new object_property($this->table, $this->id, 'agent_instance_id', 'string', false);
$this->lease_expires_at = new object_property($this->table, $this->id, 'lease_expires_at', 'string', false);
$this->last_progress_at = new object_property($this->table, $this->id, 'last_progress_at', 'string', false);
$this->attempt_count = new object_property($this->table, $this->id, 'attempt_count', 'int', false);
$this->requested_by = new object_property($this->table, $this->id, 'requested_by', 'int', false);
$this->requested_at = new object_property($this->table, $this->id, 'requested_at', 'string', false);
$this->started_at = new object_property($this->table, $this->id, 'started_at', 'string', false);
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'string', false);
$this->delivery_json = new object_property($this->table, $this->id, 'delivery_json', 'json', false);
$this->result_json = new object_property($this->table, $this->id, 'result_json', 'json', false);
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
@@ -61,16 +75,22 @@ class edge_gateway_update_jobs_o extends db
return [
'id' => (int)$this->id,
'gateway_id' => (int)$this->gateway_id->value(),
'command_job_id' => $this->command_job_id->value() === null ? null : (int)$this->command_job_id->value(),
'target_version' => (string)$this->target_version->value(),
'release_channel' => (string)$this->release_channel->value(),
'type' => (string)($this->type->value() ?? $this->operation_type->value() ?? ''),
'status' => (string)$this->status->value(),
'request' => (array)($this->request_json->value() ?? []),
'summary' => (array)($this->summary_json->value() ?? []),
'result' => (array)($this->result_json->value() ?? []),
'error_code' => $this->error_code->value() === null ? null : (string)$this->error_code->value(),
'error_message' => $this->error_message->value() === null ? null : (string)$this->error_message->value(),
'correlation_id' => (string)$this->correlation_id->value(),
'agent_instance_id' => $this->agent_instance_id->value() === null ? null : (string)$this->agent_instance_id->value(),
'lease_expires_at' => $this->lease_expires_at->value() === null ? null : (string)$this->lease_expires_at->value(),
'last_progress_at' => $this->last_progress_at->value() === null ? null : (string)$this->last_progress_at->value(),
'attempt_count' => (int)($this->attempt_count->value() ?? 0),
'requested_by' => $this->requested_by->value() === null ? null : (int)$this->requested_by->value(),
'requested_at' => (string)$this->requested_at->value(),
'started_at' => $this->started_at->value() === null ? null : (string)$this->started_at->value(),
'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(),
'delivery' => (array)($this->delivery_json->value() ?? []),
'result' => (array)($this->result_json->value() ?? []),
'created_at' => (string)$this->created_at->value(),
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
];
@@ -1,75 +0,0 @@
<?php
namespace objects;
use classes\db;
use classes\edge_gateway_schema_bootstrap;
use classes\object_property;
use traits\db_object_t;
class edge_gateway_shell_action_jobs_o extends db
{
use db_object_t;
public object_property $gateway_id;
public object_property $session_id;
public object_property $action_type;
public object_property $status;
public object_property $payload_json;
public object_property $delivery_json;
public object_property $requested_by;
public object_property $requested_at;
public object_property $completed_at;
public object_property $error_message;
public object_property $created_at;
public object_property $updated_at;
public object_property $deleted_at;
public function structure(): void
{
edge_gateway_schema_bootstrap::ensureTables();
$this->setTable('edge_gateway_shell_action_jobs');
}
public function getObjectProperties(): void
{
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
$this->session_id = new object_property($this->table, $this->id, 'session_id', 'int', false);
$this->action_type = new object_property($this->table, $this->id, 'action_type', 'string', false);
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
$this->payload_json = new object_property($this->table, $this->id, 'payload_json', 'json', false);
$this->delivery_json = new object_property($this->table, $this->id, 'delivery_json', 'json', false);
$this->requested_by = new object_property($this->table, $this->id, 'requested_by', 'int', false);
$this->requested_at = new object_property($this->table, $this->id, 'requested_at', 'string', false);
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'string', false);
$this->error_message = new object_property($this->table, $this->id, 'error_message', 'text', false);
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
}
public function objectChanged(): void
{
}
public function asArray(): array
{
$this->requireSelected();
return [
'id' => (int)$this->id,
'gateway_id' => (int)$this->gateway_id->value(),
'session_id' => (int)$this->session_id->value(),
'action_type' => (string)$this->action_type->value(),
'status' => (string)$this->status->value(),
'payload' => (array)($this->payload_json->value() ?? []),
'delivery' => (array)($this->delivery_json->value() ?? []),
'requested_by' => $this->requested_by->value() === null ? null : (int)$this->requested_by->value(),
'requested_at' => (string)$this->requested_at->value(),
'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(),
'error_message' => $this->error_message->value() === null ? null : (string)$this->error_message->value(),
'created_at' => (string)$this->created_at->value(),
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
];
}
}
@@ -1,54 +0,0 @@
<?php
namespace objects;
use classes\db;
use classes\edge_gateway_schema_bootstrap;
use classes\object_property;
use traits\db_object_t;
class edge_gateway_shell_events_o extends db
{
use db_object_t;
public object_property $gateway_id;
public object_property $session_id;
public object_property $event_type;
public object_property $payload_json;
public object_property $created_at;
public object_property $deleted_at;
public function structure(): void
{
edge_gateway_schema_bootstrap::ensureTables();
$this->setTable('edge_gateway_shell_events');
}
public function getObjectProperties(): void
{
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
$this->session_id = new object_property($this->table, $this->id, 'session_id', 'int', false);
$this->event_type = new object_property($this->table, $this->id, 'event_type', 'string', false);
$this->payload_json = new object_property($this->table, $this->id, 'payload_json', 'json', false);
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
}
public function objectChanged(): void
{
}
public function asArray(): array
{
$this->requireSelected();
return [
'id' => (int)$this->id,
'gateway_id' => (int)$this->gateway_id->value(),
'session_id' => (int)$this->session_id->value(),
'event_type' => (string)$this->event_type->value(),
'payload' => (array)($this->payload_json->value() ?? []),
'created_at' => (string)$this->created_at->value(),
];
}
}
@@ -1,80 +0,0 @@
<?php
namespace objects;
use classes\db;
use classes\edge_gateway_schema_bootstrap;
use classes\object_property;
use traits\db_object_t;
class edge_gateway_shell_sessions_o extends db
{
use db_object_t;
public object_property $gateway_id;
public object_property $reason;
public object_property $approval_status;
public object_property $session_token_hash;
public object_property $requested_by;
public object_property $approved_by;
public object_property $approved_at;
public object_property $expires_at;
public object_property $opened_at;
public object_property $closed_at;
public object_property $transcript_text;
public object_property $metadata_json;
public object_property $created_at;
public object_property $updated_at;
public object_property $deleted_at;
public function structure(): void
{
edge_gateway_schema_bootstrap::ensureTables();
$this->setTable('edge_gateway_shell_sessions');
}
public function getObjectProperties(): void
{
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
$this->reason = new object_property($this->table, $this->id, 'reason', 'text', false);
$this->approval_status = new object_property($this->table, $this->id, 'approval_status', 'string', false);
$this->session_token_hash = new object_property($this->table, $this->id, 'session_token_hash', 'string', false);
$this->requested_by = new object_property($this->table, $this->id, 'requested_by', 'int', false);
$this->approved_by = new object_property($this->table, $this->id, 'approved_by', 'int', false);
$this->approved_at = new object_property($this->table, $this->id, 'approved_at', 'string', false);
$this->expires_at = new object_property($this->table, $this->id, 'expires_at', 'string', false);
$this->opened_at = new object_property($this->table, $this->id, 'opened_at', 'string', false);
$this->closed_at = new object_property($this->table, $this->id, 'closed_at', 'string', false);
$this->transcript_text = new object_property($this->table, $this->id, 'transcript_text', 'text', false);
$this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', false);
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
}
public function objectChanged(): void
{
}
public function asArray(): array
{
$this->requireSelected();
return [
'id' => (int)$this->id,
'gateway_id' => (int)$this->gateway_id->value(),
'reason' => (string)$this->reason->value(),
'approval_status' => (string)$this->approval_status->value(),
'requested_by' => $this->requested_by->value() === null ? null : (int)$this->requested_by->value(),
'approved_by' => $this->approved_by->value() === null ? null : (int)$this->approved_by->value(),
'approved_at' => $this->approved_at->value() === null ? null : (string)$this->approved_at->value(),
'expires_at' => (string)$this->expires_at->value(),
'opened_at' => $this->opened_at->value() === null ? null : (string)$this->opened_at->value(),
'closed_at' => $this->closed_at->value() === null ? null : (string)$this->closed_at->value(),
'transcript_text' => $this->transcript_text->value() === null ? null : (string)$this->transcript_text->value(),
'metadata' => (array)($this->metadata_json->value() ?? []),
'created_at' => (string)$this->created_at->value(),
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
];
}
}
@@ -7,6 +7,8 @@ use classes\db;
use classes\email;
use classes\gatewayapi;
use classes\object_property;
use classes\order_bookings_counts_cache;
use classes\order_bookings_list_cache;
use classes\pdf_generator;
use classes\slack;
use Exception;
@@ -290,7 +292,12 @@ class order_bookings_o extends db
public function objectChanged(): void
{
//TODO: Add cache invalidation
try {
order_bookings_list_cache::clearAll();
order_bookings_counts_cache::clearAll();
} catch (\Throwable) {
// Cache invalidation must never break order-booking writes.
}
}
@@ -569,4 +576,68 @@ class order_bookings_o extends db
return count($order_ids);
}
/**
* @param array<int, int|string>|null $departmentIds
* @return array{past:int,current:int,future:int}
*/
public function getPendingBookingCounts(?array $departmentIds = null, ?int $customerNumber = null, ?\DateTimeImmutable $reference = null): array
{
global /** @var db $db */
$db;
$normalizedDepartmentIds = [];
if (is_array($departmentIds)) {
foreach ($departmentIds as $departmentId) {
$normalizedDepartmentId = (int)$departmentId;
if ($normalizedDepartmentId > 0) {
$normalizedDepartmentIds[] = $normalizedDepartmentId;
}
}
$normalizedDepartmentIds = array_values(array_unique($normalizedDepartmentIds));
if ($normalizedDepartmentIds === []) {
return [
'past' => 0,
'current' => 0,
'future' => 0,
];
}
}
$now = $reference ?? new \DateTimeImmutable('now');
$todayStart = $now->setTime(0, 0, 0);
$todayEnd = $now->setTime(23, 59, 59);
$todayStartSql = $db->escape_string($todayStart->format('Y-m-d H:i:s'));
$todayEndSql = $db->escape_string($todayEnd->format('Y-m-d H:i:s'));
$whereClauses = [
'`deleted_at` IS NULL',
"(`order_id` IS NULL OR `order_id` = 0 OR TRIM(CAST(`order_id` AS CHAR)) = '')",
];
if ($normalizedDepartmentIds !== []) {
$whereClauses[] = '`department` IN (' . implode(', ', array_map('intval', $normalizedDepartmentIds)) . ')';
}
if ($customerNumber !== null && $customerNumber > 0) {
$whereClauses[] = '`customer_number` = ' . (int)$customerNumber;
}
$sql = "SELECT
COALESCE(SUM(CASE WHEN `datetime` < '{$todayStartSql}' THEN 1 ELSE 0 END), 0) AS `past`,
COALESCE(SUM(CASE WHEN `datetime` >= '{$todayStartSql}' AND `datetime` <= '{$todayEndSql}' THEN 1 ELSE 0 END), 0) AS `current`,
COALESCE(SUM(CASE WHEN `datetime` > '{$todayEndSql}' THEN 1 ELSE 0 END), 0) AS `future`
FROM `order_bookings`
WHERE " . implode(' AND ', $whereClauses);
$result = $db->query($sql);
$row = $db->fetch_assoc($result);
return [
'past' => max(0, (int)($row['past'] ?? 0)),
'current' => max(0, (int)($row['current'] ?? 0)),
'future' => max(0, (int)($row['future'] ?? 0)),
];
}
}
+160 -17
View File
@@ -318,6 +318,16 @@ class orders_o extends db
self::objectChanged();
}
/**
* @throws Exception
*/
public function clearStripeInvoicing(): void
{
self::requireSelected();
$this->stripe_module_orders->clear();
self::objectChanged();
}
public function addArray(array $order_array): orders_o
{
global $db, $response;
@@ -375,10 +385,11 @@ class orders_o extends db
* @param int|null $invoiceCollectionId The invoice collection id, if not set, the default invoice collection id will be used.
* @throws Exception If the order is not selected
*/
public function assignToInvoiceCollection(int $invoiceCollectionId = null): void
public function assignToInvoiceCollection(int $invoiceCollectionId = null, bool $notifyChanges = true): void
{
// If the invoice collection id is not set, get the default invoice collection id
self::requireSelected();
$previousInvoiceCollectionId = (int)$this->invoice_collection_id->value();
// Get the customer
$customer = new users_o();
$customer_id = (int)$this->customer_id->value();
@@ -391,8 +402,17 @@ class orders_o extends db
$invoiceCollectionId = $invoiceCollectionId ?? $customer->getNewOrderInvoiceCollectionId();
// Assign the order to the invoice collection
$this->invoice_collection_id->set($invoiceCollectionId);
if ($previousInvoiceCollectionId > 0 && $previousInvoiceCollectionId !== (int)$invoiceCollectionId) {
$previousCollection = new collected_order_invoices_o();
$previousCollection->select($previousInvoiceCollectionId);
if ($previousCollection->exists()) {
$previousCollection->objectChanged();
}
}
if ($notifyChanges) {
self::objectChanged();
}
}
/**
* Get the recommended order items, based on the order history, vehicle plate and department
@@ -1378,14 +1398,91 @@ class orders_o extends db
public function hasWashCertificateAttached(): bool
{
self::requireSelected();
// Check if the order has a wash certificate attached
$attachments = $this->listAttachments();
foreach ( $attachments as $attachment ) {
if ($attachment->isWashCertificate()) {
return true; // Wash certificate found
return $this->listWashCertificateAttachmentIds() !== [];
}
/**
* @return int[]
* @throws Exception
*/
protected function listWashCertificateAttachmentIds(): array
{
self::requireSelected();
global $db;
$rawObjectType = trim((string)$this->table, '`');
$objectTypes = array_values(array_unique([
$db->escape_string($rawObjectType),
$db->escape_string('`' . $rawObjectType . '`'),
]));
$quotedObjectTypes = "'" . implode("','", $objectTypes) . "'";
$objectId = (int)$this->id;
$sql = "SELECT id, content
FROM object_attachments
WHERE object_type IN ($quotedObjectTypes)
AND object_id = $objectId
AND deleted_at IS NULL";
$result = $db->query($sql);
if (!$result) {
return [];
}
$attachmentIds = [];
while ($row = $db->fetch_assoc($result)) {
$content = json_decode((string)($row['content'] ?? ''), true);
$other = is_array($content) ? ($content['other'] ?? null) : null;
if (is_string($other) && strtolower($other) === 'wash_certificate') {
$attachmentIds[] = (int)($row['id'] ?? 0);
}
}
return false; // No wash certificate product found in the order items
return array_values(array_filter($attachmentIds, static fn(int $id): bool => $id > 0));
}
/**
* @throws Exception
*/
public function regenerateAttachedWashCertificate(): bool
{
self::requireSelected();
if (!$this->hasWashCertificateAttached()) {
return false;
}
$this->removeAttachedWashCertificates();
$this->generateWashCertificate(
$this->getSafetySealValue(),
$this->resolveWashCertificateOperator(),
$this->resolveWashCertificateDate()
);
return $this->hasWashCertificateAttached();
}
/**
* @throws Exception
*/
protected function removeAttachedWashCertificates(): int
{
self::requireSelected();
$attachmentIds = $this->listWashCertificateAttachmentIds();
if ($attachmentIds === []) {
return 0;
}
global $db;
$escapedIds = array_map(static fn(int $id): int => (int)$id, $attachmentIds);
$idList = implode(',', $escapedIds);
$sql = "UPDATE object_attachments
SET deleted_at = NOW()
WHERE id IN ($idList)
AND deleted_at IS NULL";
$db->query($sql);
return count($escapedIds);
}
/**
@@ -1439,6 +1536,42 @@ class orders_o extends db
return self::normalizeSafetySealValue($this->safety_seal->value());
}
/**
* @throws Exception
*/
public function resolveWashCertificateOperator(): ?string
{
self::requireSelected();
$cashierId = (int)$this->cashier_id->value();
if ($cashierId <= 0) {
return null;
}
$cashier = (new users_o())->select($cashierId);
if (!$cashier->exists()) {
return null;
}
$displayName = trim((string)$cashier->display_name->value());
return $displayName === '' ? null : $displayName;
}
/**
* @throws Exception
*/
public function resolveWashCertificateDate(): string
{
self::requireSelected();
$candidate = $this->completed_at->value() ?: $this->created_at->value();
if (is_string($candidate) && trim($candidate) !== '') {
return $candidate;
}
return date('Y-m-d H:i:s');
}
/**
* @throws Exception
*/
@@ -1482,17 +1615,27 @@ class orders_o extends db
if (!$department->exists()) {
throw new Exception('Department not found');
}
// Get branding for the department
$branding = (new branding_o())->select((int)$department->branding->value());
if (!$branding->exists()) {
throw new Exception('Branding not found');
// Branding is optional; fall back to the department values when it is not configured.
$branding = null;
$brandingId = (int)($department->branding->value() ?? 0);
if ($brandingId > 0) {
$selectedBranding = (new branding_o())->select($brandingId);
if ($selectedBranding->exists()) {
$branding = $selectedBranding;
}
}
// Get the customer for the order
$customer = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
$department_array = $department->asArray();
$department_array['branding'] = $branding->asArray();
$customer_array = $customer->asArray();
$order_array = $this->asArray();
$branding_array = $branding?->asArray() ?? [];
$customer_number = (int)$customer->customer_number->value();
$customer_name = trim((string)$customer->display_name->value());
if ($customer_name === '') {
$customer_name = (string)($customer_number > 0 ? $customer_number : $this->customer_id->value());
}
$customer_address = '-';
// Format the date as 17:35 02-12-2025
$date = ($date instanceof DateTime ? $date : ($date !== null ? new DateTime($date) : new DateTime()));
$date_formatted = ($date instanceof DateTime ? $date->format('d-m-Y') : date('d-m-Y'));
@@ -1526,10 +1669,10 @@ class orders_o extends db
'time' => $time_formatted,
'carried_out_by' => ($operator ?? null),
'department_id' => $department->id,
'department_name' => $department_array['branding']['name'] ?: $department_array['name'],
'department_address' => $department_array['branding']['address'] ?: $department_array['description'],
'customer_name' => $customer->getCustomerName($customer_array['customer_number']),
'customer_address' => $customer->getCustomerEcocomicData($customer_array['customer_number'])->economic_customer->address ?: '-',
'department_name' => ($branding_array['name'] ?? null) ?: $department_array['name'],
'department_address' => ($branding_array['address'] ?? null) ?: $department_array['description'],
'customer_name' => $customer_name,
'customer_address' => $customer_address,
'wash_type' => 'ORDER_WASH'
])
->getHtml()
@@ -93,4 +93,19 @@ class stripe_module_orders_o extends db
return (new stripe())->invoice->retrieve($this->invoice_id->value());
}
public function clear(): void
{
if (!$this->exists()) {
return;
}
$this->delete();
}
public function deleteForOrder(int $order_id): void
{
$record = (new self())->select($order_id);
$record->clear();
}
}
+13 -1
View File
@@ -2195,6 +2195,17 @@ paths:
two_factor_enabled:
type: boolean
description: Indicates if 2FA is enabled for this account
runtime_config:
type: object
properties:
economic:
type: object
properties:
transaction_draft_customer_number:
type: integer
nullable: true
additionalProperties: false
additionalProperties: true
'400':
$ref: '#/components/responses/BadRequest'
'401':
@@ -12041,12 +12052,13 @@ components:
type: object
properties:
module: { type: string, enum: [economic] }
variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber] }
variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber, transactionDraftCustomerNumber] }
type: { type: string, enum: [string, int] }
value:
oneOf:
- type: string
- type: integer
nullable: true
required: [module, variable, type, value]
RecaptchaConfigEntry:
@@ -0,0 +1,770 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "This agent must run from the CLI.\n");
exit(1);
}
final class AgentConfig
{
public function __construct(public array $data, public string $path)
{
}
public static function load(string $path): self
{
if (!is_file($path)) {
throw new RuntimeException('Missing config file: ' . $path);
}
$decoded = json_decode((string)file_get_contents($path), true);
if (!is_array($decoded)) {
throw new RuntimeException('Invalid config file: ' . $path);
}
return new self($decoded, $path);
}
public function get(string $key, mixed $default = null): mixed
{
return array_key_exists($key, $this->data) ? $this->data[$key] : $default;
}
public function set(string $key, mixed $value): void
{
$this->data[$key] = $value;
}
public function save(): void
{
file_put_contents($this->path, json_encode($this->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
}
}
final class HttpJsonClient
{
public function __construct(private readonly string $baseUrl)
{
}
public function post(string $path, array $payload, int $timeoutSeconds = 30): ?array
{
$response = $this->requestJson(
'POST',
rtrim($this->baseUrl, '/') . '/' . ltrim($path, '/'),
$payload,
$timeoutSeconds
);
return $response['decoded'];
}
public function getJson(string $url, int $timeoutSeconds = 10): array
{
$response = $this->requestJson('GET', $url, null, $timeoutSeconds);
return $response['decoded'] ?? [];
}
public function download(string $url, int $timeoutSeconds = 60): string
{
$response = $this->requestRaw('GET', $url, null, $timeoutSeconds, ['Accept: */*']);
return $response['body'];
}
private function requestJson(string $method, string $url, ?array $payload, int $timeoutSeconds): array
{
$headers = ['Accept: application/json'];
if ($payload !== null) {
$headers[] = 'Content-Type: application/json';
}
$response = $this->requestRaw($method, $url, $payload, $timeoutSeconds, $headers);
$decoded = json_decode($response['body'], true);
if (!is_array($decoded)) {
throw new RuntimeException('Invalid JSON response from ' . $method . ' ' . $url);
}
$response['decoded'] = $decoded;
return $response;
}
private function requestRaw(
string $method,
string $url,
?array $payload,
int $timeoutSeconds,
array $headers
): array {
$responseHeaders = [];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeoutSeconds,
CURLOPT_CONNECTTIMEOUT => min(10, $timeoutSeconds),
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_HEADERFUNCTION => static function ($curl, string $headerLine) use (&$responseHeaders): int {
$trimmed = trim($headerLine);
if ($trimmed !== '') {
$responseHeaders[] = $trimmed;
}
return strlen($headerLine);
},
]);
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_SLASHES));
}
$raw = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($raw === false) {
throw new RuntimeException(sprintf('%s %s failed: %s', $method, $url, $error !== '' ? $error : 'unknown curl error'));
}
if ($status >= 400) {
$preview = substr(preg_replace('/\s+/', ' ', (string)$raw) ?? '', 0, 240);
$headerPreview = implode('; ', array_slice($responseHeaders, 0, 8));
throw new RuntimeException(sprintf(
'%s %s returned HTTP %d. Headers: %s. Body preview: %s',
$method,
$url,
$status,
$headerPreview !== '' ? $headerPreview : 'none',
$preview !== '' ? $preview : 'empty'
));
}
return [
'status' => $status,
'body' => (string)$raw,
'headers' => $responseHeaders,
];
}
}
final class Logger
{
public function __construct(private readonly string $logFile)
{
}
public function info(string $message): void
{
$this->write('INFO', $message);
}
public function warning(string $message): void
{
$this->write('WARNING', $message);
}
public function error(string $message): void
{
$this->write('ERROR', $message);
}
private function write(string $level, string $message): void
{
$line = '[' . date('c') . "] {$level} {$message}\n";
file_put_contents($this->logFile, $line, FILE_APPEND);
fwrite($level === 'ERROR' ? STDERR : STDOUT, $line);
}
}
final class TruckwashEdgeAgent
{
private AgentConfig $config;
private HttpJsonClient $http;
private Logger $logger;
private string $installDir;
private string $runtimeDir;
private string $statePath;
private string $lastOperationSnapshotPath;
private string $lastHeartbeatMarkerPath;
private int $lastHeartbeatAt = 0;
private string $agentInstanceId;
public function __construct(string $configPath)
{
$this->config = AgentConfig::load($configPath);
$this->http = new HttpJsonClient((string)$this->config->get('apiUrl'));
$this->installDir = rtrim((string)$this->config->get('installDir', dirname($configPath)), DIRECTORY_SEPARATOR);
$this->runtimeDir = $this->installDir . DIRECTORY_SEPARATOR . 'runtime';
@mkdir($this->runtimeDir, 0777, true);
$this->logger = new Logger($this->runtimeDir . DIRECTORY_SEPARATOR . 'agent.log');
$this->statePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'current-operation.json';
$this->lastOperationSnapshotPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-operation.json';
$this->lastHeartbeatMarkerPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-heartbeat-ok.txt';
$this->agentInstanceId = $this->ensureAgentInstanceId();
}
public function run(): void
{
$this->logger->info('Truckwash PHP edge agent starting.');
$this->recoverPreviousOperationState();
while (true) {
try {
$this->ensureClaimed();
$this->heartbeat();
$processedManagementOperation = $this->processManagementOperation();
if (!$processedManagementOperation) {
$this->processCommandQueue();
}
} catch (Throwable $throwable) {
$this->logger->error($throwable->getMessage());
sleep(2);
}
usleep(250000);
}
}
private function ensureClaimed(): void
{
if ($this->config->get('gatewayId') && $this->config->get('agentToken')) {
return;
}
$response = $this->http->post('/edge-agent/claim', [
'token' => (string)$this->config->get('installToken'),
'hostname' => gethostname() ?: 'truckwash-edge',
'installed_version' => (string)$this->config->get('installedVersion', 'php-agent-v1'),
'metadata' => [
'runtime' => 'php-cli',
'php_version' => PHP_VERSION,
'agent_instance_id' => $this->agentInstanceId,
],
]);
$payload = $response['data'] ?? [];
$gateway = $payload['gateway'] ?? [];
$installedVersion = (string)$this->config->get('installedVersion', 'php-agent-v1');
$this->config->set('gatewayId', $gateway['id'] ?? null);
$this->config->set('agentToken', $payload['agent_token'] ?? null);
$this->config->set('installedVersion', $installedVersion);
$this->config->set('targetVersion', (string)($gateway['target_version'] ?? $installedVersion));
$this->config->set('agentInstanceId', $this->agentInstanceId);
$this->config->save();
$this->logger->info('Claimed gateway ' . (string)($gateway['id'] ?? 'unknown') . '.');
}
private function heartbeat(bool $force = false, array $metadata = []): void
{
$interval = (int)$this->config->get('heartbeatIntervalSeconds', 15);
if (!$force && (time() - $this->lastHeartbeatAt) < max(5, $interval)) {
return;
}
$gatewayId = (int)$this->config->get('gatewayId');
if ($gatewayId <= 0) {
return;
}
$operationState = $this->readOperationState();
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/heartbeat', [
'agent_token' => (string)$this->config->get('agentToken'),
'status' => 'ONLINE',
'hostname' => gethostname() ?: 'truckwash-edge',
'installed_version' => (string)$this->config->get('installedVersion', 'php-agent-v1'),
'target_version' => (string)$this->config->get('targetVersion', $this->config->get('installedVersion', 'php-agent-v1')),
'metadata' => array_merge([
'agent_instance_id' => $this->agentInstanceId,
'current_operation' => $operationState,
'system_metrics' => [
'memory_usage_bytes' => memory_get_usage(true),
'memory_peak_bytes' => memory_get_peak_usage(true),
'load_average' => function_exists('sys_getloadavg') ? sys_getloadavg() : [],
],
], $metadata),
]);
$this->lastHeartbeatAt = time();
file_put_contents($this->lastHeartbeatMarkerPath, json_encode([
'gateway_id' => $gatewayId,
'at' => date('c'),
'agent_instance_id' => $this->agentInstanceId,
], JSON_UNESCAPED_SLASHES) . PHP_EOL);
}
private function processCommandQueue(): void
{
$gatewayId = (int)$this->config->get('gatewayId');
$response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/poll', [
'agent_token' => (string)$this->config->get('agentToken'),
'wait_seconds' => (int)$this->config->get('operationPollTimeoutSeconds', 20),
]);
$command = $response['data'] ?? null;
if (!is_array($command) || empty($command['id'])) {
return;
}
$jobId = (int)$command['id'];
$type = (string)($command['command_type'] ?? $command['commandType'] ?? '');
$request = is_array($command['payload'] ?? null) ? (array)$command['payload'] : [];
try {
$result = match ($type) {
'DISCOVER_SHELLY' => $this->runDiscovery(),
'GET_RELAY_STATUS' => $this->readRelayStatus($request),
'SET_RELAY_STATE' => $this->switchRelay($request),
default => throw new RuntimeException('Unsupported command type: ' . $type),
};
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/' . $jobId . '/result', [
'agent_token' => (string)$this->config->get('agentToken'),
'ok' => true,
'result' => $result,
]);
} catch (Throwable $throwable) {
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/commands/' . $jobId . '/result', [
'agent_token' => (string)$this->config->get('agentToken'),
'ok' => false,
'error' => $throwable->getMessage(),
]);
}
}
private function processManagementOperation(): bool
{
$gatewayId = (int)$this->config->get('gatewayId');
$response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/next', [
'agent_token' => (string)$this->config->get('agentToken'),
'wait_seconds' => (int)$this->config->get('operationPollTimeoutSeconds', 20),
'agent_instance_id' => $this->agentInstanceId,
]);
$operation = $response['data'] ?? null;
if (!is_array($operation) || empty($operation['id'])) {
return false;
}
$operationId = (int)$operation['id'];
$type = (string)($operation['type'] ?? '');
$request = is_array($operation['request'] ?? null) ? (array)$operation['request'] : [];
$this->persistOperationState([
'gateway_id' => $gatewayId,
'operation_id' => $operationId,
'type' => $type,
'status' => 'IN_PROGRESS',
'stage' => 'claimed',
'progress' => 5,
'agent_instance_id' => $this->agentInstanceId,
'started_at' => date('c'),
'request' => $request,
]);
$this->emitOperationProgress(
$gatewayId,
$operationId,
'OPERATION_AGENT_STARTED',
'PHP edge agent started processing the operation',
10,
['type' => $type, 'agent_instance_id' => $this->agentInstanceId],
'starting'
);
try {
$result = match ($type) {
'DISCOVERY' => $this->runManagementDiscovery($gatewayId, $operationId, $request),
'UPDATE' => $this->runUpdate($gatewayId, $operationId, $request),
'UNINSTALL' => $this->runUninstall($gatewayId, $operationId, $request),
default => throw new RuntimeException('Unsupported operation type: ' . $type),
};
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/complete', [
'agent_token' => (string)$this->config->get('agentToken'),
'ok' => true,
'result' => $result,
]);
$this->snapshotOperationState('COMPLETED', ['result' => $result]);
$this->clearOperationState();
} catch (Throwable $throwable) {
$errorCode = $this->classifyManagementError($throwable, $type);
$this->postOperationEvent($gatewayId, $operationId, [
'level' => 'ERROR',
'code' => $errorCode,
'message' => $throwable->getMessage(),
'context' => ['type' => $type, 'progress' => 100, 'agent_instance_id' => $this->agentInstanceId],
]);
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/complete', [
'agent_token' => (string)$this->config->get('agentToken'),
'ok' => false,
'error_code' => $errorCode,
'error_message' => $throwable->getMessage(),
]);
$this->snapshotOperationState('FAILED', [
'error_code' => $errorCode,
'error_message' => $throwable->getMessage(),
]);
$this->clearOperationState();
}
return true;
}
private function postOperationEvent(int $gatewayId, int $operationId, array $payload): void
{
$this->http->post('/edge-agent/gateways/' . $gatewayId . '/operations/' . $operationId . '/events', [
'agent_token' => (string)$this->config->get('agentToken'),
'level' => (string)($payload['level'] ?? 'INFO'),
'code' => $payload['code'] ?? null,
'message' => (string)($payload['message'] ?? 'Operation event'),
'context' => is_array($payload['context'] ?? null) ? (array)$payload['context'] : [],
]);
}
private function emitOperationProgress(
int $gatewayId,
int $operationId,
string $code,
string $message,
int $progress,
array $context = [],
?string $stage = null
): void {
$operationState = $this->readOperationState();
if ($operationState !== null) {
$operationState['progress'] = max(0, min(100, $progress));
$operationState['stage'] = $stage ?? ($operationState['stage'] ?? 'running');
$operationState['updated_at'] = date('c');
$this->persistOperationState($operationState);
}
$this->heartbeat(true, [
'current_operation' => $this->readOperationState(),
]);
$this->postOperationEvent($gatewayId, $operationId, [
'level' => 'INFO',
'code' => $code,
'message' => $message,
'context' => array_merge($context, [
'progress' => max(0, min(100, $progress)),
'agent_instance_id' => $this->agentInstanceId,
]),
]);
}
private function runDiscovery(): array
{
return ['inventory' => []];
}
private function runManagementDiscovery(int $gatewayId, int $operationId, array $request): array
{
$this->emitOperationProgress($gatewayId, $operationId, 'DISCOVERY_COLLECTING', 'Collecting local gateway inventory', 35, [], 'discovering');
$hostname = gethostname() ?: 'truckwash-edge';
$fallbackInventory = [[
'device_id' => 'gateway-runtime-' . substr(sha1($hostname), 0, 10),
'local_ip' => gethostbyname($hostname),
'model' => 'TruckWash Edge Gateway',
'channel_count' => 1,
'online' => true,
'capabilities' => [
'local_discovery' => true,
'relay_commands' => true,
'gateway_management_v2' => true,
],
'metadata' => [
'hostname' => $hostname,
'runtime' => 'php-cli',
'php_version' => PHP_VERSION,
],
]];
$inventory = isset($request['inventory']) && is_array($request['inventory']) && $request['inventory'] !== []
? (array)$request['inventory']
: $fallbackInventory;
$this->emitOperationProgress($gatewayId, $operationId, 'DISCOVERY_COMPLETED', 'Inventory collected', 90, [
'device_count' => count($inventory),
], 'finishing');
return ['inventory' => $inventory];
}
private function runUpdate(int $gatewayId, int $operationId, array $request): array
{
$targetVersion = trim((string)($request['target_version'] ?? $request['targetVersion'] ?? ''));
if ($targetVersion === '') {
throw new RuntimeException('Update request is missing target version');
}
$agentArtifactUrl = trim((string)($request['artifactUrl'] ?? ''));
$serviceUnitUrl = trim((string)($request['serviceUnitUrl'] ?? ''));
$agentArtifactSha = trim((string)($request['artifactSha256'] ?? ''));
$serviceUnitSha = trim((string)($request['serviceUnitSha256'] ?? ''));
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_VALIDATED', 'Validated update request', 20, [
'target_version' => $targetVersion,
], 'validating');
$changedFiles = [];
if ($agentArtifactUrl !== '') {
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_DOWNLOAD_AGENT', 'Downloading PHP edge agent artifact', 40, [
'url' => $agentArtifactUrl,
], 'downloading-agent');
$changedFiles[] = $this->downloadToFileAtomic(
$agentArtifactUrl,
$this->installDir . DIRECTORY_SEPARATOR . 'agent.php',
$agentArtifactSha
);
}
if ($serviceUnitUrl !== '') {
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_DOWNLOAD_SERVICE', 'Downloading service unit', 65, [
'url' => $serviceUnitUrl,
], 'downloading-service');
$changedFiles[] = $this->downloadToFileAtomic(
$serviceUnitUrl,
$this->installDir . DIRECTORY_SEPARATOR . 'truckwash-edge-agent.service',
$serviceUnitSha
);
}
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_WRITE_CONFIG', 'Writing staged update metadata', 85, [], 'writing-config');
$this->config->set('installedVersion', $targetVersion);
$this->config->set('targetVersion', $targetVersion);
$this->config->set('lastStagedUpdate', [
'target_version' => $targetVersion,
'staged_at' => date('c'),
'changed_files' => $changedFiles,
]);
$this->config->save();
$this->emitOperationProgress($gatewayId, $operationId, 'UPDATE_STAGED', 'Artifacts staged successfully', 95, [
'changed_files' => $changedFiles,
], 'staged');
return [
'installed_version' => $targetVersion,
'target_version' => $targetVersion,
'restart_required' => true,
'service_name' => (string)$this->config->get('serviceName', 'truckwash-edge-agent.service'),
'changed_files' => $changedFiles,
'agent_instance_id' => $this->agentInstanceId,
];
}
private function runUninstall(int $gatewayId, int $operationId, array $request): array
{
$this->emitOperationProgress($gatewayId, $operationId, 'UNINSTALL_PREPARE', 'Preparing uninstall manifest', 40, [], 'preparing-uninstall');
$manifestPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'uninstall-plan.json';
$manifest = [
'service_name' => (string)($request['service_name'] ?? $this->config->get('serviceName', 'truckwash-edge-agent.service')),
'install_dir' => (string)($request['install_dir'] ?? $this->installDir),
'generated_at' => date('c'),
'agent_instance_id' => $this->agentInstanceId,
];
file_put_contents($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
file_put_contents($this->runtimeDir . DIRECTORY_SEPARATOR . 'uninstall.flag', date('c') . PHP_EOL);
$this->config->set('pendingUninstall', $manifest);
$this->config->save();
$this->emitOperationProgress($gatewayId, $operationId, 'UNINSTALL_READY', 'Gateway marked for controlled uninstall', 90, [
'manifest_path' => $manifestPath,
], 'uninstall-ready');
return [
'uninstalled' => true,
'service_name' => $manifest['service_name'],
'install_dir' => $manifest['install_dir'],
'manual_cleanup_required' => true,
'cleanup_manifest_path' => $manifestPath,
];
}
private function readRelayStatus(array $request): array
{
$localIp = (string)($request['localIp'] ?? $request['local_ip'] ?? '');
$channel = (int)($request['channel'] ?? 0);
return $this->fetchShellyState($localIp, $channel);
}
private function switchRelay(array $request): array
{
$localIp = (string)($request['localIp'] ?? $request['local_ip'] ?? '');
$channel = (int)($request['channel'] ?? 0);
$on = (bool)($request['on'] ?? false);
$rpcUrl = sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s', $localIp, $channel, $on ? 'true' : 'false');
try {
$this->http->getJson($rpcUrl, 8);
} catch (Throwable) {
$legacyUrl = sprintf('http://%s/relay/%d?turn=%s', $localIp, $channel, $on ? 'on' : 'off');
$this->http->getJson($legacyUrl, 8);
}
return $this->fetchShellyState($localIp, $channel);
}
private function downloadToFileAtomic(string $url, string $path, string $expectedSha256 = ''): array
{
$directory = dirname($path);
if (!is_dir($directory)) {
@mkdir($directory, 0777, true);
}
$temporaryPath = $path . '.download';
$backupPath = $path . '.bak';
$raw = $this->http->download($url, 60);
file_put_contents($temporaryPath, $raw);
if ($expectedSha256 !== '') {
$actualSha = hash_file('sha256', $temporaryPath);
if (!is_string($actualSha) || !hash_equals(strtolower($expectedSha256), strtolower($actualSha))) {
@unlink($temporaryPath);
throw new RuntimeException('Artifact checksum mismatch for ' . basename($path));
}
}
if (is_file($backupPath)) {
@unlink($backupPath);
}
if (is_file($path) && !@rename($path, $backupPath)) {
@unlink($temporaryPath);
throw new RuntimeException('Could not create backup for ' . $path);
}
if (!@rename($temporaryPath, $path)) {
if (is_file($backupPath)) {
@rename($backupPath, $path);
}
@unlink($temporaryPath);
throw new RuntimeException('Could not promote downloaded artifact for ' . $path);
}
return [
'path' => $path,
'url' => $url,
'backup_path' => is_file($backupPath) ? $backupPath : null,
'sha256' => hash_file('sha256', $path),
];
}
private function classifyManagementError(Throwable $throwable, string $type): string
{
$message = strtolower(trim($throwable->getMessage()));
if (str_contains($message, 'token') || str_contains($message, 'credential')) {
return 'EDGE_GATEWAY_INVALID_TOKEN';
}
if (str_contains($message, 'timeout') || str_contains($message, 'http 5')) {
return 'EDGE_GATEWAY_OPERATION_TIMEOUT';
}
if ($type === 'UPDATE' && (str_contains($message, 'version') || str_contains($message, 'checksum'))) {
return 'EDGE_GATEWAY_UNSUPPORTED_VERSION';
}
return 'EDGE_GATEWAY_VALIDATION_FAILED';
}
private function fetchShellyState(string $localIp, int $channel): array
{
if ($localIp === '') {
throw new RuntimeException('Missing Shelly IP address');
}
try {
$payload = $this->http->getJson(sprintf('http://%s/rpc/Switch.GetStatus?id=%d', $localIp, $channel), 8);
return [
'online' => true,
'on' => (bool)($payload['output'] ?? false),
'output' => (bool)($payload['output'] ?? false),
'raw' => $payload,
];
} catch (Throwable) {
$payload = $this->http->getJson(sprintf('http://%s/relay/%d', $localIp, $channel), 8);
return [
'online' => true,
'on' => (bool)($payload['ison'] ?? false),
'output' => (bool)($payload['ison'] ?? false),
'raw' => $payload,
];
}
}
private function ensureAgentInstanceId(): string
{
$configured = trim((string)$this->config->get('agentInstanceId', ''));
if ($configured !== '') {
return substr($configured, 0, 128);
}
$generated = sprintf(
'%s-%s',
preg_replace('/[^A-Za-z0-9\-]+/', '-', gethostname() ?: 'truckwash-edge') ?: 'truckwash-edge',
substr(bin2hex(random_bytes(6)), 0, 12)
);
$this->config->set('agentInstanceId', $generated);
$this->config->save();
return $generated;
}
private function recoverPreviousOperationState(): void
{
$state = $this->readOperationState();
if ($state === null) {
return;
}
$this->logger->warning(sprintf(
'Recovered previous unfinished operation snapshot: operation %s (%s) last stage %s.',
(string)($state['operation_id'] ?? 'unknown'),
(string)($state['type'] ?? 'unknown'),
(string)($state['stage'] ?? 'unknown')
));
}
private function persistOperationState(array $state): void
{
file_put_contents($this->statePath, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
}
private function snapshotOperationState(string $status, array $extra = []): void
{
$state = $this->readOperationState() ?? [];
$state['status'] = $status;
$state['finished_at'] = date('c');
$state = array_merge($state, $extra);
file_put_contents($this->lastOperationSnapshotPath, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
}
private function clearOperationState(): void
{
if (is_file($this->statePath)) {
@unlink($this->statePath);
}
}
private function readOperationState(): ?array
{
if (!is_file($this->statePath)) {
return null;
}
$decoded = json_decode((string)file_get_contents($this->statePath), true);
return is_array($decoded) ? $decoded : null;
}
}
$configPath = null;
foreach ($argv as $index => $argument) {
if ($argument === '--config' && isset($argv[$index + 1])) {
$configPath = $argv[$index + 1];
break;
}
}
if (!is_string($configPath) || trim($configPath) === '') {
fwrite(STDERR, "Usage: agent.php --config /path/to/config.json\n");
exit(1);
}
(new TruckwashEdgeAgent($configPath))->run();
@@ -0,0 +1,14 @@
[Unit]
Description=TruckWash Edge Agent
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/truckwash-edge-agent
ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php --config /opt/truckwash-edge-agent/config.json
Restart=always
RestartSec=5
User=root
[Install]
WantedBy=multi-user.target
+21
View File
@@ -116,6 +116,12 @@ class authRoute
try {
$cached = redis->get_auth_session($token);
if (is_array($cached)) {
$cached = $this->appendRuntimeConfig($cached);
try {
redis->cache_auth_session($token, $cached, 60);
} catch (\Throwable $e) {
// Best-effort caching only
}
$response->success($cached);
}
} catch (\Throwable $e) {
@@ -131,6 +137,7 @@ class authRoute
$user_data = $user->includeIncludes(['economicCustomer', 'permissions'])->asArray();
$user_data['two_factor_enabled'] = $user->isTwoFactorEnabled();
$user_data = $this->appendRuntimeConfig($user_data);
// Cache the session payload briefly to reduce DB load on hot paths
try {
@@ -810,4 +817,18 @@ class authRoute
(new logs_o())->add('auth', 'global', 0, 0, $action, $message);
}
private function appendRuntimeConfig(array $payload): array
{
$payload['runtime_config'] = array_replace_recursive(
is_array($payload['runtime_config'] ?? null) ? $payload['runtime_config'] : [],
[
'economic' => [
'transaction_draft_customer_number' => (new economic())->getTransactionDraftCustomerNumber(),
],
]
);
return $payload;
}
}
@@ -5,6 +5,7 @@ namespace routes;
use classes\authentication;
use classes\department_outside_hours_statistics_service;
use classes\workfeed;
use classes\workfeed_shift_time_resolver;
use customers\economicCustomers;
use DateInterval;
use DateTime;
@@ -1654,53 +1655,7 @@ class departmentDailyReportsRoute
private function calculateShiftOvertimeHoursInRange(array $record, DateTime $range_start, DateTime $range_end_exclusive): float
{
$shift_start = $this->firstShiftDateTimeFromPaths($record, [
'actualStart',
'actualStartTime',
'clockIn',
'clockInTime',
'start',
'startTime',
'from',
'approval.originalStart',
]);
$scheduled_end = $this->lastShiftDateTimeFromPaths($record, [
'end',
'endTime',
'to',
]);
$effective_end = $this->lastShiftDateTimeFromPaths($record, [
'actualEnd',
'actualEndTime',
'clockOut',
'clockOutTime',
'end',
'endTime',
'to',
'approval.originalEnd',
'approval.end',
]);
if ($shift_start === null || $scheduled_end === null || $effective_end === null) {
return 0.0;
}
$effective_end = $this->resolveEffectiveShiftEnd($record, $shift_start, $effective_end);
$scheduled_end_ts = $scheduled_end->getTimestamp();
$effective_end_ts = $effective_end->getTimestamp();
if ($effective_end_ts <= $scheduled_end_ts) {
return 0.0;
}
$overtime_start_ts = max($scheduled_end_ts, $range_start->getTimestamp());
$overtime_end_ts = min($effective_end_ts, $range_end_exclusive->getTimestamp());
if ($overtime_end_ts <= $overtime_start_ts) {
return 0.0;
}
return round(($overtime_end_ts - $overtime_start_ts) / 3600, 2);
return workfeed_shift_time_resolver::calculateOvertimeHoursInRange($record, $range_start, $range_end_exclusive);
}
/**
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\economic;
use classes\economic_transfer_queue;
use classes\response;
use classes\router;
@@ -45,6 +46,11 @@ class economicInvoiceRoute
if (!$order->exists()) {
$response->error('Order not found', 404);
}
try {
$this->assertOrderCanBeExportedToEconomic((int)$order_id);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
if (!$this->isEconomicTransferQueueAvailable()) {
try {
@@ -158,6 +164,11 @@ class economicInvoiceRoute
if (!$customer->exists()) {
$response->error('Customer not found', 404);
}
try {
(new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
$economic_module_orders = (new economic_module_orders())->getByOrderId((int)$order_id);
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
$response->error('No economic invoice draft found', 404);
@@ -365,6 +376,7 @@ class economicInvoiceRoute
if (!$customer->exists()) {
throw new \Exception('Customer not found');
}
(new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
$customer_economic = $customer->getCustomerEcocomicData()->economic_customer;
$economic_invoice_draft = new economic_invoice_draft_mo();
@@ -449,6 +461,7 @@ class economicInvoiceRoute
if (!$customer->exists()) {
throw new \Exception('Customer not found');
}
(new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
@@ -622,4 +635,17 @@ class economicInvoiceRoute
$response->error('Economic transfer queue is unavailable in this deployment', 503);
}
}
/**
* @throws \Exception
*/
private function assertOrderCanBeExportedToEconomic(int $order_id): void
{
$customer = (new orders_o())->getCustomerByOrderId($order_id);
if (!$customer->exists()) {
throw new \Exception('Customer not found');
}
(new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
}
}
+304 -431
View File
@@ -3,9 +3,14 @@
namespace routes;
use classes\authentication;
use classes\edge_gateway_install_service;
use classes\edge_gateway_manager;
use classes\edge_gateway_operation_exception;
use classes\edge_gateway_operation_service;
use classes\edge_gateway_registry_service;
use classes\edge_gateway_view_service;
use classes\response;
use objects\logs_o;
use Exception;
use traits\route_t;
class edgeGatewaysRoute
@@ -14,244 +19,223 @@ class edgeGatewaysRoute
public function run(): void
{
$this->get('/edge-gateways', function () {
$this->get('/edge-gateways', fn() => $this->handleListGateways(), [
'modules_shelly_config' => 'Manage department edge gateways for local Shelly control',
]);
$this->get('/edge-gateways/{id}', fn() => $this->handleGatewayDetail(), [
'modules_shelly_config' => 'View department edge gateway detail',
]);
$this->put('/edge-gateways/{id}', fn() => $this->handleGatewayUpdate(), [
'modules_shelly_config' => 'Update edge gateway metadata and primary assignment',
]);
$this->get('/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationsList(), [
'modules_shelly_config' => 'List edge gateway operations',
]);
$this->post('/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationCreate(), [
'modules_shelly_config' => 'Queue an edge gateway operation',
]);
$this->get('/edge-gateways/{id}/operations/{operationId}/events', fn() => $this->handleGatewayOperationEvents(), [
'modules_shelly_config' => 'List edge gateway operation events',
]);
$this->post('/edge-gateways/{id}/rotate-credentials', fn() => $this->handleGatewayCredentialRotate(), [
'modules_shelly_config' => 'Rotate edge gateway credentials',
]);
$this->post('/edge-gateways/install-token', fn() => $this->handleInstallTokenCreate(), [
'modules_shelly_config' => 'Create a one-time Raspberry Pi edge gateway installer token',
]);
$this->post('/edge-gateways/{id}/discovery', fn() => $this->handleGatewayDiscovery(), [
'modules_shelly_config' => 'Queue Shelly discovery through the local edge gateway',
]);
$this->put('/edge-gateways/{id}/bindings', fn() => $this->handleBindingsUpdate(), [
'modules_shelly_config' => 'Approve or override relay bindings for an edge gateway',
]);
$this->delete('/edge-gateways/{id}', fn() => $this->handleGatewayDelete(), [
'modules_shelly_config' => 'Delete an edge gateway registration',
]);
$this->post('/departments/{id}/gateway-cutover', fn() => $this->handleDepartmentCutover(), [
'modules_shelly_config' => 'Cut a department over from Shelly cloud to local edge gateways',
]);
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
$this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php'));
$this->get('/edge-agent/artifacts/truckwash-edge-agent.service', fn() => $this->renderArtifact('truckwash-edge-agent.service'));
$this->post('/edge-agent/claim', fn() => $this->handleAgentClaim());
$this->post('/edge-agent/gateways/{id}/heartbeat', fn() => $this->handleAgentHeartbeat());
$this->post('/edge-agent/gateways/{id}/operations/next', fn() => $this->handleAgentOperationNext());
$this->post('/edge-agent/gateways/{id}/operations/{operationId}/events', fn() => $this->handleAgentOperationEvent());
$this->post('/edge-agent/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleAgentOperationComplete());
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
$this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence());
}
private function handleListGateways(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$departmentId = self::isParametersSet(['department_id']) ? (int)self::getParameter('department_id') : null;
$view = trim((string)$this->fromQuery('view'));
if ($departmentId !== null && $departmentId > 0) {
$this->requireDepartmentAccess((int)$departmentId);
$this->requireDepartmentAccess($departmentId);
}
$response->success((new edge_gateway_manager())->listGateways($departmentId));
}, [
'modules_shelly_config' => 'Manage department edge gateways for local Shelly control',
]);
$gateways = $this->views()->listGateways($departmentId, $view !== 'summary');
$response->add_meta('fleet_usage', $this->views()->buildFleetUsageStatistics($departmentId, $gateways));
$response->success($gateways);
}
$this->get('/edge-gateways/{id}', function () {
private function handleGatewayDetail(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$response->success($this->requireGatewayAccess((int)$this->fromRoute('id')));
}
private function handleGatewayUpdate(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['label', 'is_primary']);
self::requireType(self::getParameter('label'), self::TYPE_STRING());
self::requireType(self::getParameter('is_primary'), self::TYPE_BOOL());
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$gateway = (new edge_gateway_manager())->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$response->success($gateway);
}, [
'modules_shelly_config' => 'View department edge gateway detail',
]);
$this->requireGatewayAccess($gatewayId);
$this->post('/edge-gateways/install-token', function () {
$response->success($this->registry()->updateGatewayMetadata($gatewayId, [
'label' => (string)self::getParameter('label'),
'is_primary' => (bool)self::getParameter('is_primary'),
], $this->actorUserId()));
}
private function handleGatewayOperationsList(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
$response->success($this->operations()->listOperations($gatewayId));
}
private function handleGatewayOperationCreate(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['type', 'request']);
self::requireType(self::getParameter('type'), self::TYPE_STRING());
self::requireType(self::getParameter('request'), self::TYPE_ARRAY());
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
try {
$operation = $this->operations()->queueOperation(
$gatewayId,
(string)self::getParameter('type'),
(array)self::getParameter('request'),
$this->actorUserId()
);
$response->success([
'operation' => $operation,
'gateway' => $this->views()->getGateway($gatewayId),
], 201);
} catch (edge_gateway_operation_exception $exception) {
$response->error([
'message' => $exception->getMessage(),
'error_code' => $exception->errorCode,
], $exception->status);
}
}
private function handleGatewayOperationEvents(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$operationId = (int)$this->fromRoute('operationId');
self::requireParameterIntPositive($operationId, 'operationId');
$this->requireGatewayAccess($gatewayId);
$response->success($this->operations()->listOperationEvents($gatewayId, $operationId));
}
private function handleGatewayCredentialRotate(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
$response->success($this->operations()->rotateCredentials($gatewayId, $this->actorUserId()));
}
private function handleInstallTokenCreate(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['department_id']);
$departmentId = (int)self::getParameter('department_id');
self::requireParameterIntPositive($departmentId, 'department_id');
$this->requireDepartmentAccess($departmentId);
$user = (new authentication())->get_user();
$response->success(
(new edge_gateway_manager())->createInstallToken(
$this->registry()->createInstallToken(
$departmentId,
self::isParametersSet(['label']) ? (string)self::getParameter('label') : null,
$user ? (int)$user->id : null
$this->actorUserId()
),
201
);
}, [
'modules_shelly_config' => 'Create a one-time Raspberry Pi edge gateway installer token',
]);
}
$this->post('/edge-gateways/{id}/discovery', function () {
private function handleGatewayDiscovery(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$this->requireGatewayAccess($gatewayId);
$user = (new authentication())->get_user();
$response->success($manager->queueDiscovery($gatewayId, $user ? (int)$user->id : null));
}, [
'modules_shelly_config' => 'Trigger a Shelly LAN discovery job on an edge gateway',
]);
$this->operations()->queueDiscoveryOperation($gatewayId, $this->actorUserId());
$response->success($this->views()->getGateway($gatewayId));
}
$this->put('/edge-gateways/{id}/bindings', function () {
private function handleBindingsUpdate(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['bindings']);
self::requireType(self::getParameter('bindings'), self::TYPE_ARRAY());
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$this->requireGatewayAccess($gatewayId);
$this->registry()->setRelayBindings($gatewayId, (array)self::getParameter('bindings'), $this->actorUserId());
$response->success($this->views()->getGateway($gatewayId));
}
$user = (new authentication())->get_user();
$response->success($manager->setRelayBindings(
$gatewayId,
(array)self::getParameter('bindings'),
$user ? (int)$user->id : null
));
}, [
'modules_shelly_config' => 'Approve or override relay bindings for an edge gateway',
]);
$this->post('/edge-gateways/{id}/update-jobs', function () {
private function handleGatewayDelete(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['target_version']);
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$this->requireGatewayAccess($gatewayId);
$response->success($this->registry()->deleteGateway($gatewayId, $this->actorUserId()));
}
$user = (new authentication())->get_user();
$response->success($manager->queueUpdate(
$gatewayId,
(string)self::getParameter('target_version'),
self::isParametersSet(['release_channel']) ? (string)self::getParameter('release_channel') : edge_gateway_manager::DEFAULT_RELEASE_CHANNEL,
$user ? (int)$user->id : null
), 201);
}, [
'modules_shelly_config' => 'Queue an automatic edge gateway update',
]);
$this->post('/edge-gateways/{id}/uninstall', function () {
private function handleDepartmentCutover(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$user = (new authentication())->get_user();
$response->success($manager->queueUninstall($gatewayId, $user ? (int)$user->id : null), 202);
}, [
'modules_shelly_config' => 'Queue an edge gateway uninstall on the Raspberry Pi',
]);
$this->post('/edge-gateways/{id}/shell-sessions', function () {
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['reason']);
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
self::requireMinLength('reason', 5);
$user = (new authentication())->get_user();
$response->success($manager->createShellSessionRequest(
$gatewayId,
(string)self::getParameter('reason'),
[
'cols' => self::isParametersSet(['cols']) ? (int)self::getParameter('cols') : null,
'rows' => self::isParametersSet(['rows']) ? (int)self::getParameter('rows') : null,
],
$user ? (int)$user->id : null
), 201);
}, [
'modules_shelly_config' => 'Approve a break-glass root shell on an edge gateway',
]);
$this->get('/edge-gateways/{id}/shell-sessions/{sessionId}/events', fn() => $this->handleShellSessionEvents(), [
'modules_shelly_config' => 'Poll root shell events for an edge gateway',
]);
$this->post('/edge-gateways/{id}/shell-sessions/{sessionId}/input', fn() => $this->handleShellSessionInput(), [
'modules_shelly_config' => 'Send terminal input to an edge gateway root shell',
]);
$this->post('/edge-gateways/{id}/shell-sessions/{sessionId}/resize', fn() => $this->handleShellSessionResize(), [
'modules_shelly_config' => 'Resize an edge gateway root shell terminal',
]);
$this->post('/edge-gateways/{id}/shell-sessions/{sessionId}/close', fn() => $this->handleShellSessionClose(), [
'modules_shelly_config' => 'Close an approved edge gateway root shell session',
]);
$this->post('/edge-gateways/{id}/rotate-credentials', function () {
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$user = (new authentication())->get_user();
$response->success($manager->rotateGatewayCredentials($gatewayId, $user ? (int)$user->id : null));
}, [
'modules_shelly_config' => 'Rotate edge gateway agent credentials',
]);
$this->delete('/edge-gateways/{id}', function () {
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$user = (new authentication())->get_user();
$response->success($manager->deleteGateway($gatewayId, $user ? (int)$user->id : null));
}, [
'modules_shelly_config' => 'Delete an edge gateway registration',
]);
$this->post('/departments/{id}/gateway-cutover', function () {
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['transport_mode']);
$departmentId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($departmentId, 'id');
$this->requireDepartmentAccess($departmentId);
self::requireParameters(['transport_mode']);
$user = (new authentication())->get_user();
$response->success((new edge_gateway_manager())->setDepartmentTransportMode(
$departmentId,
(string)self::getParameter('transport_mode'),
$user ? (int)$user->id : null
));
}, [
'modules_shelly_config' => 'Cut a department over from Shelly cloud to local edge gateways',
]);
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
$this->get('/edge-agent/artifacts/agent.mjs', fn() => $this->renderAgentArtifact('agent.mjs', 'application/javascript; charset=utf-8'));
$this->get('/edge-agent/artifacts/package.json', fn() => $this->renderAgentArtifact('package.json', 'application/json; charset=utf-8'));
$this->post('/edge-agent/claim', fn() => $this->handleAgentClaim());
$this->post('/edge-agent/gateways/{id}/heartbeat', fn() => $this->handleAgentHeartbeat());
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
$this->post('/edge-agent/gateways/{id}/shell-actions/poll', fn() => $this->handleAgentShellActionPoll());
$this->post('/edge-agent/gateways/{id}/shell-actions/{actionId}/result', fn() => $this->handleAgentShellActionResult());
$this->post('/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events', fn() => $this->handleAgentShellEvents());
$this->post('/edge-agent/internal/gateways/{id}/validate', fn() => $this->handleBrokerGatewayValidate());
$this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellValidate());
$this->post('/edge-agent/internal/gateways/{id}/presence', fn() => $this->handleBrokerGatewayPresence());
$this->post('/edge-agent/internal/shell-sessions/close', fn() => $this->handleBrokerShellSessionClose());
$response->success($this->registry()->setDepartmentTransportMode($departmentId, (string)self::getParameter('transport_mode'), $this->actorUserId()));
}
private function renderInstallScript(): void
{
$manager = new edge_gateway_manager();
$token = trim((string)$this->fromQuery('token'));
if ($token === '') {
http_response_code(400);
@@ -260,7 +244,7 @@ class edgeGatewaysRoute
}
header('Content-Type: text/x-shellscript; charset=utf-8');
echo $manager->buildInstallScript($token);
echo $this->install()->buildInstallScript($token);
exit;
}
@@ -272,21 +256,20 @@ class edgeGatewaysRoute
$response->error('Missing token', 400);
}
$response->success((new edge_gateway_manager())->verifyInstallToken($token));
$response->success($this->install()->verifyInstallToken($token));
}
private function renderAgentArtifact(string $fileName, string $contentType): void
private function renderArtifact(string $fileName): void
{
$artifactPath = dirname(WD, 3) . '/services/edge-agent/dist/' . $fileName;
if (!is_file($artifactPath)) {
try {
header('Content-Type: ' . $this->install()->contentType($fileName));
echo $this->install()->readArtifact($fileName);
exit;
} catch (Exception $exception) {
http_response_code(404);
echo 'Missing edge agent artifact';
echo $exception->getMessage();
exit;
}
header('Content-Type: ' . $contentType);
echo file_get_contents($artifactPath);
exit;
}
private function handleAgentClaim(): void
@@ -294,40 +277,103 @@ class edgeGatewaysRoute
global /** @var response $response */ $response;
self::requireParameters(['token']);
$payload = self::getParametersAsArray();
$response->success((new edge_gateway_manager())->claimGateway(
$response->success(
$this->registry()->claimGateway(
(string)$payload['token'],
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
(array)($payload['metadata'] ?? [])
), 201);
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
),
201
);
}
private function handleAgentHeartbeat(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$payload = self::getParametersAsArray();
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
if ($token === '') {
$response->error('Missing edge gateway agent token', 401);
$response->success($this->registry()->recordHeartbeat($gatewayId, $this->requireAgentToken($payload), $payload));
}
private function handleAgentOperationNext(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$payload = self::getParametersAsArray();
try {
$response->success($this->operations()->claimNextOperation(
$gatewayId,
$this->requireAgentToken($payload),
isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : edge_gateway_manager::COMMAND_POLL_TIMEOUT_SECONDS,
isset($payload['agent_instance_id']) ? (string)$payload['agent_instance_id'] : null
));
} catch (edge_gateway_operation_exception $exception) {
$response->error([
'message' => $exception->getMessage(),
'error_code' => $exception->errorCode,
], $exception->status);
}
}
private function handleAgentOperationEvent(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$operationId = (int)$this->fromRoute('operationId');
self::requireParameterIntPositive($operationId, 'operationId');
$payload = self::getParametersAsArray();
try {
$response->success($this->operations()->appendAgentOperationEvent(
$gatewayId,
$operationId,
$this->requireAgentToken($payload),
$payload
));
} catch (edge_gateway_operation_exception $exception) {
$response->error([
'message' => $exception->getMessage(),
'error_code' => $exception->errorCode,
], $exception->status);
}
}
private function handleAgentOperationComplete(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$operationId = (int)$this->fromRoute('operationId');
self::requireParameterIntPositive($operationId, 'operationId');
$payload = self::getParametersAsArray();
try {
$response->success($this->operations()->completeAgentOperation(
$gatewayId,
$operationId,
$this->requireAgentToken($payload),
$payload
));
} catch (edge_gateway_operation_exception $exception) {
$response->error([
'message' => $exception->getMessage(),
'error_code' => $exception->errorCode,
], $exception->status);
}
$response->success((new edge_gateway_manager())->recordHeartbeat($gatewayId, $token, $payload));
}
private function handleAgentCommandPoll(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$payload = self::getParametersAsArray();
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
if ($token === '') {
$response->error('Missing edge gateway agent token', 401);
}
$waitSeconds = isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : edge_gateway_manager::COMMAND_POLL_TIMEOUT_SECONDS;
$response->success((new edge_gateway_manager())->pollCommand($gatewayId, $token, $waitSeconds));
$response->success($this->manager()->pollCommand(
$gatewayId,
$this->requireAgentToken($payload),
isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : edge_gateway_manager::COMMAND_POLL_TIMEOUT_SECONDS
));
}
private function handleAgentCommandResult(): void
@@ -335,256 +381,83 @@ class edgeGatewaysRoute
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$jobId = (int)$this->fromRoute('jobId');
self::requireParameterIntPositive($gatewayId, 'id');
self::requireParameterIntPositive($jobId, 'jobId');
$payload = self::getParametersAsArray();
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
if ($token === '') {
$response->error('Missing edge gateway agent token', 401);
}
$response->success((new edge_gateway_manager())->submitCommandResult(
$response->success($this->manager()->submitCommandResult(
$gatewayId,
$jobId,
$token,
$this->requireAgentToken($payload),
(bool)($payload['ok'] ?? false),
isset($payload['payload']) && is_array($payload['payload']) ? (array)$payload['payload'] : [],
isset($payload['result']) && is_array($payload['result']) ? (array)$payload['result'] : [],
isset($payload['error']) ? (string)$payload['error'] : null
));
}
private function handleShellSessionEvents(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$sessionId = (int)$this->fromRoute('sessionId');
self::requireParameterIntPositive($gatewayId, 'id');
self::requireParameterIntPositive($sessionId, 'sessionId');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$afterId = max(0, (int)$this->fromQuery('after_id'));
$waitSeconds = (int)$this->fromQuery('wait_seconds');
if ($waitSeconds <= 0) {
$waitSeconds = edge_gateway_manager::SHELL_EVENT_POLL_TIMEOUT_SECONDS;
}
$response->success($manager->pollShellSessionEvents($gatewayId, $sessionId, $afterId, $waitSeconds));
}
private function handleShellSessionInput(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['data']);
$gatewayId = (int)$this->fromRoute('id');
$sessionId = (int)$this->fromRoute('sessionId');
self::requireParameterIntPositive($gatewayId, 'id');
self::requireParameterIntPositive($sessionId, 'sessionId');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$user = (new authentication())->get_user();
$response->success($manager->enqueueShellInput(
$gatewayId,
$sessionId,
(string)self::getParameter('data'),
$user ? (int)$user->id : null
));
}
private function handleShellSessionResize(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
self::requireParameters(['cols', 'rows']);
$gatewayId = (int)$this->fromRoute('id');
$sessionId = (int)$this->fromRoute('sessionId');
self::requireParameterIntPositive($gatewayId, 'id');
self::requireParameterIntPositive($sessionId, 'sessionId');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$user = (new authentication())->get_user();
$response->success($manager->enqueueShellResize(
$gatewayId,
$sessionId,
(int)self::getParameter('cols'),
(int)self::getParameter('rows'),
$user ? (int)$user->id : null
));
}
private function handleShellSessionClose(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$sessionId = (int)$this->fromRoute('sessionId');
self::requireParameterIntPositive($gatewayId, 'id');
self::requireParameterIntPositive($sessionId, 'sessionId');
$manager = new edge_gateway_manager();
$gateway = $manager->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
$user = (new authentication())->get_user();
$response->success($manager->requestShellSessionClose(
$gatewayId,
$sessionId,
$user ? (int)$user->id : null
));
}
private function handleAgentShellActionPoll(): void
private function handleAgentPresence(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$payload = self::getParametersAsArray();
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
if ($token === '') {
$response->error('Missing edge gateway agent token', 401);
}
$waitSeconds = isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : edge_gateway_manager::SHELL_ACTION_POLL_TIMEOUT_SECONDS;
$response->success((new edge_gateway_manager())->pollShellAction($gatewayId, $token, $waitSeconds));
}
$this->requireAgentToken($payload);
private function handleAgentShellActionResult(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$actionId = (int)$this->fromRoute('actionId');
self::requireParameterIntPositive($gatewayId, 'id');
self::requireParameterIntPositive($actionId, 'actionId');
$payload = self::getParametersAsArray();
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
if ($token === '') {
$response->error('Missing edge gateway agent token', 401);
}
$response->success((new edge_gateway_manager())->submitShellActionResult(
$response->success($this->manager()->recordBrokerPresence(
$gatewayId,
$actionId,
$token,
(bool)($payload['ok'] ?? false),
isset($payload['error']) ? (string)$payload['error'] : null
));
}
private function handleAgentShellEvents(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$sessionId = (int)$this->fromRoute('sessionId');
self::requireParameterIntPositive($gatewayId, 'id');
self::requireParameterIntPositive($sessionId, 'sessionId');
$payload = self::getParametersAsArray();
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
if ($token === '') {
$response->error('Missing edge gateway agent token', 401);
}
$events = isset($payload['events']) && is_array($payload['events'])
? (array)$payload['events']
: (isset($payload['event']) && is_array($payload['event']) ? [(array)$payload['event']] : []);
$response->success((new edge_gateway_manager())->recordShellSessionEvents(
$gatewayId,
$sessionId,
$token,
$events
));
}
private function handleBrokerGatewayValidate(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSharedSecret();
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$payload = self::getParametersAsArray();
self::requireParameters(['token']);
$response->success((new edge_gateway_manager())->validateBrokerAgentConnection(
$gatewayId,
(string)$payload['token']
));
}
private function handleBrokerShellValidate(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSharedSecret();
$payload = self::getParametersAsArray();
self::requireParameters(['token']);
$response->success((new edge_gateway_manager())->validateShellSessionToken((string)$payload['token']));
}
private function handleBrokerGatewayPresence(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSharedSecret();
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$payload = self::getParametersAsArray();
self::requireParameters(['status']);
$response->success((new edge_gateway_manager())->recordBrokerPresence(
$gatewayId,
(string)$payload['status'],
isset($payload['status']) ? (string)$payload['status'] : 'disconnected',
isset($payload['connection_id']) ? (string)$payload['connection_id'] : null,
isset($payload['reason']) ? (string)$payload['reason'] : null,
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
));
}
private function handleBrokerShellSessionClose(): void
private function requireGatewayAccess(int $gatewayId): array
{
self::requireParameterIntPositive($gatewayId, 'id');
$gateway = $this->views()->getGateway($gatewayId);
$this->requireDepartmentAccess((int)$gateway['department_id']);
return $gateway;
}
private function requireAgentToken(array $payload): string
{
global /** @var response $response */ $response;
$this->requireBrokerSharedSecret();
$payload = self::getParametersAsArray();
self::requireParameters(['token']);
$response->success((new edge_gateway_manager())->closeShellSession(
(string)$payload['token'],
isset($payload['transcript']) ? (string)$payload['transcript'] : '',
isset($payload['reason']) ? (string)$payload['reason'] : 'broker_closed'
));
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
if ($token === '') {
$response->error('Missing edge gateway agent token', 401);
}
private function requireBrokerSharedSecret(): void
return $token;
}
private function actorUserId(): ?int
{
global /** @var response $response */ $response;
$expected = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
if ($expected === '') {
$response->error('Edge broker secret is not configured', 503);
$user = (new authentication())->get_user();
return $user ? (int)$user->id : null;
}
$provided = trim((string)($_SERVER['HTTP_X_EDGE_BROKER_SECRET'] ?? $this->fromRequest('broker_secret')));
if ($provided === '' || !hash_equals($expected, $provided)) {
$response->error('Forbidden', 403);
private function views(): edge_gateway_view_service
{
return new edge_gateway_view_service();
}
private function registry(): edge_gateway_registry_service
{
return new edge_gateway_registry_service();
}
private function manager(): edge_gateway_manager
{
return new edge_gateway_manager();
}
private function operations(): edge_gateway_operation_service
{
return new edge_gateway_operation_service($this->manager());
}
private function install(): edge_gateway_install_service
{
return new edge_gateway_install_service();
}
}
@@ -101,6 +101,30 @@ class moduleStripeRoute
// Check if the order exists
$order = (new orders_o())->select((int)self::fromRequest('order_id'));
$order->requireSelected();
if ($order->stripe_module_orders->exists()) {
$existingInvoice = [];
$shouldBlockInvoiceCreation = true;
try {
$existingInvoice = $order->stripe_module_orders->asArray();
$retrievedInvoice = $order->stripe_module_orders->retrievePaymentLink();
$existingStatus = (string)($retrievedInvoice->status ?? '');
$isTerminalInvoiceState = (bool)($retrievedInvoice->paid ?? false) === true
|| in_array($existingStatus, ['paid', 'void', 'uncollectible', 'deleted'], true);
$shouldBlockInvoiceCreation = !$isTerminalInvoiceState;
} catch (\Throwable) {
$shouldBlockInvoiceCreation = false;
}
if ($shouldBlockInvoiceCreation) {
$response->error([
'message' => 'A Stripe payment link is already active for this order.',
'code' => 'stripe_invoice_exists',
'stripeModuleOrders' => $existingInvoice,
], 409);
}
$order->clearStripeInvoicing();
}
// Log the action
(new logs_o())->add('modules_stripe', 'global', 1, $user->id, 'MODULES_STRIPE', 'User sent an invoice');
// Create a customer account, if it doesn't exist
@@ -141,6 +165,56 @@ class moduleStripeRoute
]
);
/** Modules > Stripe > Cancel Invoice */
$this->delete('/modules/stripe/invoice', function () {
global $response;
self::requirePermission('modules_stripe_invoice_send');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('modules_stripe', 'global', 0, 0, 'MODULES_STRIPE', 'User tried to cancel a Stripe invoice without a valid session');
$response->error('Invalid session', 400);
}
self::requireParameters(['order_id']);
self::requireType((int)self::fromRequest('order_id'), self::TYPE_INT());
self::requireMinLength('order_id', 1);
self::requireMaxLength('order_id', 255);
$order = (new orders_o())->select((int)self::fromRequest('order_id'));
$order->requireSelected();
if (!$order->stripe_module_orders->exists()) {
$response->success([
'stripeModuleOrders' => [],
]);
}
$invoice = $order->stripe_module_orders->retrievePaymentLink();
if ((bool)($invoice->paid ?? false) === true) {
$response->error([
'message' => 'A paid Stripe payment link cannot be cancelled.',
'code' => 'stripe_invoice_paid',
'stripeModuleOrders' => $order->stripe_module_orders->asArray(),
], 409);
}
$invoiceStatus = (string)($invoice->status ?? '');
if ($invoiceStatus !== 'void' && $invoiceStatus !== 'uncollectible' && $invoiceStatus !== 'deleted') {
(new stripe())->invoice->void((string)$order->stripe_module_orders->invoice_id->value());
}
$order->clearStripeInvoicing();
(new logs_o())->add('modules_stripe', 'global', 1, $user->id, 'MODULES_STRIPE', 'User cancelled a Stripe invoice');
$response->success([
'stripeModuleOrders' => [],
]);
},
[
'modules_stripe_invoice_send' => 'Send invoice'
]
);
self::get('/modules/stripe/terminal/readers', function () {
global $response;
self::requirePermission('modules_stripe_terminal_readers_list');
@@ -7,6 +7,7 @@ use classes\response;
use classes\router;
use classes\weatherapi;
use classes\workfeed;
use classes\workfeed_shift_time_resolver;
use DateInterval;
use DateTime;
use DateTimeZone;
@@ -1634,7 +1635,12 @@ class moduleWeatherAPIRoute
return $update_time;
}
private function calculateWorkfeedEmployeeHoursForHour(array $shifts, string|array $workfeed_department_ids, DateTime $slot_start): float
private function calculateWorkfeedEmployeeHoursForHour(
array $shifts,
string|array $workfeed_department_ids,
DateTime $slot_start,
?DateTime $occurred_until = null
): float
{
$department_id_values = is_array($workfeed_department_ids) ? $workfeed_department_ids : [$workfeed_department_ids];
$department_id_lookup = [];
@@ -1652,6 +1658,7 @@ class moduleWeatherAPIRoute
$slot_end->add(new DateInterval('PT1H'));
$slot_start_ts = $slot_start->getTimestamp();
$slot_end_ts = $slot_end->getTimestamp();
$occurred_until_ts = ($occurred_until ?? new DateTime())->getTimestamp();
$hours = 0.0;
foreach ($shifts as $shift) {
@@ -1660,36 +1667,13 @@ class moduleWeatherAPIRoute
continue;
}
$record = self::normalizeWorkfeedRecord($shift);
$shift_start = self::firstShiftDateTimeFromPaths($record, [
'actualStart',
'actualStartTime',
'clockIn',
'clockInTime',
'start',
'startTime',
'from',
'approval.originalStart',
]);
$shift_end = self::lastShiftDateTimeFromPaths($record, [
'actualEnd',
'actualEndTime',
'clockOut',
'clockOutTime',
'end',
'endTime',
'to',
'approval.originalEnd',
'approval.end',
]);
if ($shift_start === null || $shift_end === null) {
$timing = workfeed_shift_time_resolver::resolveShiftTiming($shift);
if ($timing === null) {
continue;
}
$shift_end = self::resolveEffectiveShiftEnd($record, $shift_start, $shift_end);
$shift_start_ts = $shift_start->getTimestamp();
$shift_end_ts = $shift_end->getTimestamp();
$shift_start_ts = $timing['actualStart']->getTimestamp();
$shift_end_ts = min($timing['actualEnd']->getTimestamp(), $occurred_until_ts);
if ($shift_end_ts <= $shift_start_ts) {
continue;
}
+179 -5
View File
@@ -3,6 +3,8 @@
namespace routes;
use classes\authentication;
use classes\order_bookings_counts_cache;
use classes\order_bookings_list_cache;
use classes\redis;
use Exception;
use modules\subusers\helpers\subusers_permission_node_key;
@@ -132,17 +134,41 @@ class orderBookingRoute
*/
$object = new order_bookings_o(); // New object for listing
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
$response->success($object->listObjectsWithPaginationIfSet(
function ($booking) { return (new order_bookings_o())->select((int)$booking['id'])->asArray(); },
$object->forceRestrictFilters([
$forcedFilters = $object->forceRestrictFilters([
...($has_permission_other && $user !== false ? [
'department' => $user->getGroup()->getDepartments()
] : []),
...(!$has_permission_other && $effectiveCustomer !== null ? [
'customer_number' => [(int)$effectiveCustomer]
] : [])
])
));
]);
$cacheTtl = order_bookings_list_cache::getTtl();
$cacheKey = $cacheTtl > 0 ? $this->getOrderBookingsListCacheKey($object, $forcedFilters) : null;
if ($cacheKey !== null) {
$cachedPayload = order_bookings_list_cache::getPayload($cacheKey);
if ($cachedPayload !== null) {
$response->rawJson($cachedPayload);
}
}
$result = $object->listObjectsWithPaginationIfSet(
function ($booking) { return (new order_bookings_o())->select((int)$booking['id'])->asArray(); },
$forcedFilters
);
$payload = [
'success' => true,
'data' => $result,
'meta' => $response->get_meta(),
'includes' => $response->get_includes(),
];
if ($cacheKey !== null) {
order_bookings_list_cache::storePayload($cacheKey, $payload, $cacheTtl);
}
$response->rawJson($payload);
},
[
'list_own_bookings' => 'Permission to list own order bookings.',
@@ -150,6 +176,77 @@ class orderBookingRoute
]
);
$this->get('/order-bookings/counts', function () {
global $response;
$auth = new authentication();
$user = $auth->get_user();
$permission_own = self::definePermission('list_own_bookings', subusers_permission_node_key::BOOKINGS_LIST);
$permission_other = self::definePermission('list_bookings');
$hasPermissionOwn = self::hasPermission($permission_own);
$hasPermissionOther = self::hasPermission($permission_other);
if (!$hasPermissionOwn && !$hasPermissionOther) {
$this->emitForbidden([$permission_own, $permission_other]);
}
$requestedDepartmentId = $this->getTargetCountDepartmentId();
if ($hasPermissionOther && $requestedDepartmentId !== null) {
self::requireDepartmentAccess((string)$requestedDepartmentId);
}
$effectiveCustomer = null;
if (!$hasPermissionOther) {
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
if ($effectiveCustomer === null || $effectiveCustomer < 1) {
$this->emitForbidden([$permission_other]);
}
}
$departmentIds = null;
if ($requestedDepartmentId !== null) {
$departmentIds = [$requestedDepartmentId];
} elseif ($hasPermissionOther && $user !== false) {
$departmentIds = array_map('intval', $user->getGroup()->getDepartments());
if ($departmentIds === []) {
$response->success([
'past' => 0,
'current' => 0,
'future' => 0,
]);
}
}
$cacheTtl = order_bookings_counts_cache::getTtl();
$cacheKey = $cacheTtl > 0
? $this->getOrderBookingsCountsCacheKey($departmentIds, $effectiveCustomer)
: null;
if ($cacheKey !== null) {
$cachedCounts = order_bookings_counts_cache::getCounts($cacheKey);
if ($cachedCounts !== null) {
$response->success($cachedCounts);
}
}
$counts = (new order_bookings_o())->getPendingBookingCounts(
$departmentIds,
$effectiveCustomer
);
if ($cacheKey !== null) {
order_bookings_counts_cache::storeCounts($cacheKey, $counts, $cacheTtl);
}
$response->success($counts);
},
[
'list_own_bookings' => 'Permission to list own order booking counts.',
'list_bookings' => 'Permission to list department order booking counts.',
]
);
$this->put('/order-bookings', function () {
// Require the user to be logged in
global $response;
@@ -300,6 +397,83 @@ class orderBookingRoute
}
private function getOrderBookingsListCacheKey(order_bookings_o $object, string $forcedFilters): string
{
global $response;
$page = (int)($response->getRequestParameter('page') ?? 0);
if ($page < 1) {
$page = 1;
}
$limit = (int)($response->getRequestParameter('limit') ?? 0);
if ($limit < 1) {
$limit = 1000;
}
return order_bookings_list_cache::buildKey([
'route' => '/order-bookings',
'page' => $page,
'limit' => $limit,
'search' => (string)($response->getRequestParameter('search') ?? ''),
'order' => $this->normalizeOrderBookingsListCacheOrder((string)($response->getRequestParameter('order') ?? 'id:ASC')),
'filters' => $object->filter_string_to_array($forcedFilters),
]);
}
private function getOrderBookingsCountsCacheKey(?array $departmentIds, ?int $customerNumber): string
{
return order_bookings_counts_cache::buildKey([
'route' => '/order-bookings/counts',
'day' => (new \DateTimeImmutable('now'))->format('Y-m-d'),
'department_ids' => $departmentIds ?? [],
'customer_number' => $customerNumber,
]);
}
private function normalizeOrderBookingsListCacheOrder(string $order): array
{
$normalized = trim($order);
if ($normalized === '') {
$normalized = 'id:ASC';
}
$parts = array_pad(explode(':', $normalized, 2), 2, 'ASC');
$field = trim((string)$parts[0]);
$direction = strtoupper(trim((string)$parts[1]));
if ($field === '') {
$field = 'id';
}
if ($direction === '') {
$direction = 'ASC';
}
return [$field => $direction];
}
private function getTargetCountDepartmentId(): ?int
{
global $response;
$parameter = 'department';
$rawValue = $this->fromRequest($parameter);
if ($rawValue === null || $rawValue === '') {
return null;
}
if (!is_numeric($rawValue)) {
$response->error('Invalid department', 400);
}
$value = (int)$rawValue;
if ($value < 1 || $value > 999999999) {
$response->error('Invalid department', 400);
}
return $value;
}
/**
* @throws Exception
*/
@@ -557,6 +557,11 @@ class orderInvoicesRoute
$collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id'));
$collected_order_invoices->requireSelected();
try {
$this->assertCollectedInvoiceCanBeExportedToEconomic($collected_order_invoices);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
if ($collected_order_invoices->booked_invoice_id->value() !== null) {
$response->error('Invoice has already been booked', 400);
@@ -992,6 +997,11 @@ class orderInvoicesRoute
// Validate the ID against the database
$collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id'));
$collected_order_invoices->requireSelected();
try {
$this->assertCollectedInvoiceCanBeExportedToEconomic($collected_order_invoices);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
// Require the external ID to be set
if ($collected_order_invoices->external_id->value() === null) {
$response->error('Transaction has not been created in Stripe', 400);
@@ -1782,6 +1792,8 @@ class orderInvoicesRoute
*/
private function exportCollectedInvoiceSynchronously(collected_order_invoices_o $collected_order_invoices, bool $send_as_is): array
{
$this->assertCollectedInvoiceCanBeExportedToEconomic($collected_order_invoices);
if ($collected_order_invoices->external_id->value() === null) {
if (!$send_as_is) {
$customer_fixed_pricing_o = new customer_fixed_pricing_o();
@@ -2023,6 +2035,15 @@ class orderInvoicesRoute
}, $jobs));
}
/**
* @throws Exception
*/
private function assertCollectedInvoiceCanBeExportedToEconomic(collected_order_invoices_o $collected_order_invoices): void
{
$collected_order_invoices->requireSelected();
(new economic())->assertCustomerNumberIsNotDraft((int)$collected_order_invoices->customer_number->value());
}
/**
* @param $collected_order_invoice
* @param users_o $users
+58 -10
View File
@@ -6,6 +6,7 @@ use attachments\helpers\attachment_content;
use classes\attachment_store;
use classes\attachments;
use classes\authentication;
use classes\economic;
use classes\orders_input_normalizer;
use classes\response;
use classes\stripe;
@@ -986,19 +987,26 @@ class ordersRoute
break;
}
};
$shouldRefreshAttachedWashCertificate = false;
// PO
if (isset($data['po'])) {
$order->po->set((string)$data['po']);
}
// Registration numbers
if (isset($data['reg_1'])) {
$order->reg_1->set($this->normalizeRegistrationNumberOrError($data['reg_1']));
$normalizedReg1 = $this->normalizeRegistrationNumberOrError($data['reg_1']);
$shouldRefreshAttachedWashCertificate = true;
$order->reg_1->set($normalizedReg1);
}
if (isset($data['reg_2'])) {
$order->reg_2->set($this->normalizeRegistrationNumberOrError($data['reg_2']));
$normalizedReg2 = $this->normalizeRegistrationNumberOrError($data['reg_2']);
$shouldRefreshAttachedWashCertificate = true;
$order->reg_2->set($normalizedReg2);
}
if (isset($data['reg_3'])) {
$order->reg_3->set($this->normalizeRegistrationNumberOrError($data['reg_3']));
$normalizedReg3 = $this->normalizeRegistrationNumberOrError($data['reg_3']);
$shouldRefreshAttachedWashCertificate = true;
$order->reg_3->set($normalizedReg3);
}
// Reference
if (isset($data['reference'])) {
@@ -1009,7 +1017,12 @@ class ordersRoute
$order->notes->set((string)$data['notes']);
}
if (array_key_exists('safety_seal', $data)) {
$order->setSafetySealValue($data['safety_seal']);
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($data['safety_seal']);
$shouldRefreshAttachedWashCertificate = true;
$order->setSafetySealValue($normalizedSafetySeal);
}
if ($shouldRefreshAttachedWashCertificate) {
$order->regenerateAttachedWashCertificate();
}
// Register the change
$order->objectChanged();
@@ -1019,12 +1032,22 @@ class ordersRoute
// Admin/department path (requires edit_order)
self::requirePermission($permission_other);
/** Departmental access */
$originalCustomerNumber = (int)$order->customer_id->value();
$newCustomerNumber = $originalCustomerNumber;
$shouldAutoReassignInvoiceCollection = false;
$shouldRefreshAttachedWashCertificate = false;
// If the customer ID is set, validate it
if (isset($data['customer_id'])) {
if (!(new users_o())->getUserByCustomerNumber((int)$data['customer_id'])->exists() || empty($data['customer_id'])) {
$response->error('Customer not found or invalid', 400);
}
$order->customer_id->set((int)$data['customer_id']);
$newCustomerNumber = (int)$data['customer_id'];
$shouldRefreshAttachedWashCertificate = true;
$shouldAutoReassignInvoiceCollection = $this->shouldAutoReassignInvoiceCollectionForDraftTransition(
$originalCustomerNumber,
$newCustomerNumber
);
$order->customer_id->set($newCustomerNumber);
}
// If the reference is set, validate it
if (isset($data['reference'])) {
@@ -1036,22 +1059,30 @@ class ordersRoute
}
// If the registration number is set, validate it
if (isset($data['reg_1'])) {
$order->reg_1->set($this->normalizeRegistrationNumberOrError($data['reg_1']));
$normalizedReg1 = $this->normalizeRegistrationNumberOrError($data['reg_1']);
$shouldRefreshAttachedWashCertificate = true;
$order->reg_1->set($normalizedReg1);
}
// If the registration number 2 is set, validate it
if (isset($data['reg_2'])) {
$order->reg_2->set($this->normalizeRegistrationNumberOrError($data['reg_2']));
$normalizedReg2 = $this->normalizeRegistrationNumberOrError($data['reg_2']);
$shouldRefreshAttachedWashCertificate = true;
$order->reg_2->set($normalizedReg2);
}
// If the registration number 3 is set, validate it
if (isset($data['reg_3'])) {
$order->reg_3->set($this->normalizeRegistrationNumberOrError($data['reg_3']));
$normalizedReg3 = $this->normalizeRegistrationNumberOrError($data['reg_3']);
$shouldRefreshAttachedWashCertificate = true;
$order->reg_3->set($normalizedReg3);
}
// If the PO is set, validate it
if (isset($data['po'])) {
$order->po->set((string)$data['po']);
}
if (array_key_exists('safety_seal', $data)) {
$order->setSafetySealValue($data['safety_seal']);
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($data['safety_seal']);
$shouldRefreshAttachedWashCertificate = true;
$order->setSafetySealValue($normalizedSafetySeal);
}
// If the lane is set, validate it
if (isset($data['lane'])) {
@@ -1069,7 +1100,7 @@ class ordersRoute
$order->booking_id->set((int)$data['booking_id']);
}
// Check if the invoice collection is set
if (isset($data['invoice_collection_id'])) {
if (isset($data['invoice_collection_id']) && !$shouldAutoReassignInvoiceCollection) {
$order->invoice_collection_id->set((int)$data['invoice_collection_id']);
}
// Check if the wash_id is set
@@ -1094,6 +1125,12 @@ class ordersRoute
$response->error($e->getMessage(), 400);
}
}
if ($shouldAutoReassignInvoiceCollection) {
$order->assignToInvoiceCollection(null, false);
}
if ($shouldRefreshAttachedWashCertificate) {
$order->regenerateAttachedWashCertificate();
}
// Void any cached key for the order
$order->objectChanged();
// Log the incident
@@ -1149,6 +1186,17 @@ class ordersRoute
}
}
private function shouldAutoReassignInvoiceCollectionForDraftTransition(int $originalCustomerNumber, int $newCustomerNumber): bool
{
if ($originalCustomerNumber <= 0 || $newCustomerNumber <= 0 || $originalCustomerNumber === $newCustomerNumber) {
return false;
}
$economic = new economic();
return $economic->isDraftCustomerNumber($originalCustomerNumber)
|| $economic->isDraftCustomerNumber($newCustomerNumber);
}
/**
* @param array<int, array<string, mixed>> $orders
* @return array<int, array<string, mixed>>
+26 -1
View File
@@ -76,6 +76,7 @@ it('returns the cached auth session payload for a valid token', function (): voi
'two_factor_enabled' => false,
]
);
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', '445566', 'int');
$response = api_client()->get('/auth/session', $session['headers']);
@@ -89,7 +90,31 @@ it('returns the cached auth session payload for a valid token', function (): voi
->toHaveKey('customer_number', $session['user']['customer_number'])
->toHaveKey('two_factor_enabled', false)
->and($response->data()['permissions'])
->toContain('list_departments');
->toContain('list_departments')
->and($response->data()['runtime_config']['economic']['transaction_draft_customer_number'] ?? null)
->toBe(445566);
});
it('includes economic runtime config for uncached auth sessions', function (): void {
api_test_covers('GET /auth/session', 'happy');
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', '556677', 'int');
$session = api_fixtures()->createUserSession(['list_departments'], [
'display_name' => 'Fresh Session User',
]);
$response = api_client()->get('/auth/session', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toBeArray()
->toHaveKey('customer_number', $session['user']['customer_number'])
->and($response->data()['runtime_config']['economic']['transaction_draft_customer_number'] ?? null)
->toBe(556677);
});
it('rejects invalid auth session tokens', function (): void {
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
usesApiSuite();
const ECONOMIC_DRAFT_CUSTOMER_BLOCKED_MESSAGE = 'Transactions for the configured draft customer cannot be exported to e-conomic.';
function economic_draft_customer_find_config_entry(array $entries, string $variable): ?array
{
foreach ($entries as $entry) {
if (is_array($entry) && ($entry['variable'] ?? null) === $variable) {
return $entry;
}
}
return null;
}
it('lists the draft customer config entry in economic config responses', function (): void {
api_test_covers('GET /economic/config', 'happy');
$session = api_fixtures()->createUserSession(['economic_config']);
$response = api_client()->get('/economic/config', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$entry = economic_draft_customer_find_config_entry((array)$response->data(), 'transactionDraftCustomerNumber');
expect($entry)
->not->toBeNull()
->and($entry['module'] ?? null)->toBe('economic')
->and($entry['type'] ?? null)->toBe('int')
->and(is_int($entry['value'] ?? null) || ($entry['value'] ?? null) === null)->toBeTrue();
});
it('round-trips the draft customer config value through economic config updates', function (): void {
api_test_covers('POST /economic/config', 'happy');
$session = api_fixtures()->createUserSession(['economic_config']);
api_client()->post('/economic/config', [
'variable' => 'transactionDraftCustomerNumber',
'value' => 667788,
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$configuredResponse = api_client()->get('/economic/config?variable=transactionDraftCustomerNumber', $session['headers']);
$configuredResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$configuredEntry = economic_draft_customer_find_config_entry((array)$configuredResponse->data(), 'transactionDraftCustomerNumber');
expect($configuredEntry)
->not->toBeNull()
->and($configuredEntry['value'] ?? null)->toBe(667788);
api_client()->post('/economic/config', [
'variable' => 'transactionDraftCustomerNumber',
'value' => null,
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$clearedResponse = api_client()->get('/economic/config?variable=transactionDraftCustomerNumber', $session['headers']);
$clearedResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$clearedEntry = economic_draft_customer_find_config_entry((array)$clearedResponse->data(), 'transactionDraftCustomerNumber');
expect($clearedEntry)->toBeArray();
expect(array_key_exists('value', $clearedEntry))->toBeTrue();
expect($clearedEntry['value'])->toBeNull();
});
it('rejects order draft exports for the configured draft customer', function (): void {
api_test_covers('POST /economic/invoice/draft/export', 'failure');
$draftCustomer = api_fixtures()->createUser(['display_name' => 'Draft Export Customer']);
$department = api_fixtures()->createDepartment();
$cashier = api_fixtures()->createUser(['display_name' => 'Draft Export Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $draftCustomer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
'reference' => 'BLOCK-DRAFT',
'reg_1' => 'DRFT123',
]);
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int');
$session = api_fixtures()->createUserSession(['economic_invoice_draft_export']);
api_client()->post('/economic/invoice/draft/export', [
'order_id' => $order['id'],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(ECONOMIC_DRAFT_CUSTOMER_BLOCKED_MESSAGE);
});
it('rejects booked invoice exports for the configured draft customer', function (): void {
api_test_covers('POST /economic/invoice/export', 'failure');
$draftCustomer = api_fixtures()->createUser(['display_name' => 'Booked Export Customer']);
$department = api_fixtures()->createDepartment();
$cashier = api_fixtures()->createUser(['display_name' => 'Booked Export Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $draftCustomer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
'reference' => 'BLOCK-INVOICE',
'reg_1' => 'INV1234',
]);
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int');
$session = api_fixtures()->createUserSession(['economic_invoice_export']);
api_client()->post('/economic/invoice/export', [
'order_id' => $order['id'],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(ECONOMIC_DRAFT_CUSTOMER_BLOCKED_MESSAGE);
});
it('rejects collected invoice exports for the configured draft customer', function (): void {
api_test_covers('POST /collected-invoices/economic', 'failure');
$draftCustomer = api_fixtures()->createUser(['display_name' => 'Collected Export Customer']);
$invoiceCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $draftCustomer['customer_number'],
]);
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int');
$session = api_fixtures()->createUserSession(['add_collected_invoice_economic']);
api_client()->post('/collected-invoices/economic', [
'id' => $invoiceCollection['id'],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(ECONOMIC_DRAFT_CUSTOMER_BLOCKED_MESSAGE);
});
@@ -4,6 +4,39 @@ declare(strict_types=1);
usesApiSuite();
function activeWashCertificateAttachmentIdsForOrder(int $orderId): array
{
$statement = api_test_runtime()->db()->prepare(
'SELECT id, content
FROM object_attachments
WHERE object_type IN (?, ?)
AND object_id = ?
AND deleted_at IS NULL
ORDER BY id ASC'
);
expect($statement)->not->toBeFalse();
$objectType = 'orders';
$backtickedObjectType = '`orders`';
$statement->bind_param('ssi', $objectType, $backtickedObjectType, $orderId);
$statement->execute();
$result = $statement->get_result();
$attachmentIds = [];
while ($row = $result->fetch_assoc()) {
$content = json_decode((string)($row['content'] ?? ''), true);
$other = is_array($content) ? ($content['other'] ?? null) : null;
if (is_string($other) && strtolower($other) === 'wash_certificate') {
$attachmentIds[] = (int)($row['id'] ?? 0);
}
}
$result->free();
$statement->close();
return array_values(array_filter($attachmentIds, static fn(int $id): bool => $id > 0));
}
it('lists orders for an admin-scoped user and limits the results to the permitted departments', function (): void {
api_test_covers('GET /orders', 'happy');
@@ -297,6 +330,190 @@ it('updates orders through the primary endpoint', function (): void {
expect((int)($row['include_in_invoice'] ?? 1))->toBe(0);
});
it('reassigns invoice collections when changing an order across the draft customer boundary', function (): void {
api_test_covers('PUT /orders', 'happy');
$draftCustomer = api_fixtures()->createUser(['display_name' => 'Draft Customer']);
$regularCustomer = api_fixtures()->createUser(['display_name' => 'Regular Customer']);
$department = api_fixtures()->createDepartment(['name' => 'Draft Boundary Department']);
$cashier = api_fixtures()->createUser(['display_name' => 'Draft Boundary Cashier']);
api_fixtures()->setModuleConfig('economic', 'transactionDraftCustomerNumber', (string)$draftCustomer['customer_number'], 'int');
$order = api_fixtures()->createOrder([
'customer_id' => $regularCustomer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
'reference' => 'DRAFT-BOUNDARY',
'reg_1' => 'DRAFT123',
]);
$session = api_fixtures()->createUserSession(['edit_order']);
api_client()->put('/orders', [
'id' => $order['id'],
'customer_id' => $draftCustomer['customer_number'],
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess()
->assertMessage('Order updated successfully');
$draftRow = api_fixtures()->fetchRowById('orders', (int)$order['id']);
expect($draftRow)->not->toBeNull();
$draftInvoiceCollectionId = (int)($draftRow['invoice_collection_id'] ?? 0);
expect($draftInvoiceCollectionId)->toBeGreaterThan(0);
$draftCollectionRow = api_fixtures()->fetchRowById('collected_order_invoices', $draftInvoiceCollectionId);
expect($draftCollectionRow)->not->toBeNull();
expect((int)($draftCollectionRow['customer_number'] ?? 0))->toBe((int)$draftCustomer['customer_number']);
api_client()->put('/orders', [
'id' => $order['id'],
'customer_id' => $regularCustomer['customer_number'],
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess()
->assertMessage('Order updated successfully');
$regularRow = api_fixtures()->fetchRowById('orders', (int)$order['id']);
expect($regularRow)->not->toBeNull();
$regularInvoiceCollectionId = (int)($regularRow['invoice_collection_id'] ?? 0);
expect($regularInvoiceCollectionId)->toBeGreaterThan(0);
$regularCollectionRow = api_fixtures()->fetchRowById('collected_order_invoices', $regularInvoiceCollectionId);
expect($regularCollectionRow)->not->toBeNull();
expect((int)($regularCollectionRow['customer_number'] ?? 0))->toBe((int)$regularCustomer['customer_number']);
});
it('regenerates attached wash certificates when certificate metadata changes through the primary endpoint', function (): void {
api_test_covers('PUT /orders', 'happy');
$department = api_fixtures()->createDepartment(['name' => 'Wash Certificate Department']);
$customer = api_fixtures()->createUser(['display_name' => 'Original Certificate Customer']);
$updatedCustomer = api_fixtures()->createUser(['display_name' => 'Updated Certificate Customer']);
$cashier = api_fixtures()->createUser(['display_name' => 'Certificate Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
'reference' => 'CERT-REF',
'reg_1' => 'CERT123',
'reg_2' => 'TRAIL1',
'safety_seal' => 'SEAL-OLD',
'created_at' => '2026-04-12 10:15:00',
]);
$session = api_fixtures()->createUserSession([
'edit_order',
'add_order_attachments',
'department_access_' . $department['id'],
]);
api_client()->post('/orders/attachments/upload', [
'order_id' => $order['id'],
'base64_file' => base64_encode('wash-certificate'),
'file_name' => 'wash_certificate',
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$initialAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']);
expect($initialAttachmentIds)->toHaveCount(1);
$initialAttachmentId = $initialAttachmentIds[0];
$initialAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $initialAttachmentId);
expect($initialAttachmentRow)->not->toBeNull();
$initialDocument = (json_decode((string)($initialAttachmentRow['content'] ?? ''), true) ?? [])['document'] ?? null;
api_client()->put('/orders', [
'id' => $order['id'],
'customer_id' => $updatedCustomer['customer_number'],
'reg_2' => ' new-trail-55 ',
'safety_seal' => 'SEAL-NEW',
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess()
->assertMessage('Order updated successfully');
$orderRow = api_fixtures()->fetchRowById('orders', (int)$order['id']);
expect($orderRow)->not->toBeNull();
expect((int)($orderRow['customer_id'] ?? 0))->toBe((int)$updatedCustomer['customer_number']);
expect($orderRow['reg_2'] ?? null)->toBe('NEWTRAIL55');
expect($orderRow['safety_seal'] ?? null)->toBe('SEAL-NEW');
$updatedAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']);
expect($updatedAttachmentIds)->toHaveCount(1);
expect($updatedAttachmentIds)->not->toContain($initialAttachmentId);
$updatedAttachmentId = $updatedAttachmentIds[0];
$updatedAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $updatedAttachmentId);
$deletedAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $initialAttachmentId);
expect($updatedAttachmentRow)->not->toBeNull();
expect($deletedAttachmentRow)->not->toBeNull();
expect($deletedAttachmentRow['deleted_at'] ?? null)->not->toBeNull();
expect((json_decode((string)($updatedAttachmentRow['content'] ?? ''), true) ?? [])['document'] ?? null)->not->toBe($initialDocument);
});
it('regenerates attached wash certificates for legacy field-value updates through the alias endpoint', function (): void {
api_test_covers('PUT /order', 'happy');
$department = api_fixtures()->createDepartment(['name' => 'Alias Wash Certificate Department']);
$customer = api_fixtures()->createUser(['display_name' => 'Alias Certificate Customer']);
$cashier = api_fixtures()->createUser(['display_name' => 'Alias Certificate Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
'reference' => 'ALIAS-CERT',
'reg_1' => 'ALIAS123',
'safety_seal' => 'ALIAS-SEAL',
]);
$session = api_fixtures()->createUserSession([
'edit_order',
'add_order_attachments',
'department_access_' . $department['id'],
]);
api_client()->post('/orders/attachments/upload', [
'order_id' => $order['id'],
'base64_file' => base64_encode('wash-certificate'),
'file_name' => 'wash_certificate',
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$initialAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']);
expect($initialAttachmentIds)->toHaveCount(1);
$initialAttachmentId = $initialAttachmentIds[0];
api_client()->put('/order', [
'id' => $order['id'],
'field' => 'reg_1',
'value' => ' zz-88 11 ',
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess()
->assertMessage('Order updated successfully');
$orderRow = api_fixtures()->fetchRowById('orders', (int)$order['id']);
expect($orderRow)->not->toBeNull();
expect($orderRow['reg_1'] ?? null)->toBe('ZZ8811');
$updatedAttachmentIds = activeWashCertificateAttachmentIdsForOrder((int)$order['id']);
expect($updatedAttachmentIds)->toHaveCount(1);
expect($updatedAttachmentIds)->not->toContain($initialAttachmentId);
$deletedAttachmentRow = api_fixtures()->fetchRowById('object_attachments', $initialAttachmentId);
expect($deletedAttachmentRow)->not->toBeNull();
expect($deletedAttachmentRow['deleted_at'] ?? null)->not->toBeNull();
});
it('supports legacy field-value metadata updates through the primary endpoint', function (): void {
api_test_covers('PUT /orders', 'happy');
@@ -2,8 +2,26 @@
declare(strict_types=1);
use classes\email;
use classes\stripe;
use classes\stripe_fake_http_client;
putenv('STRIPE_FAKE_MODE=1');
putenv('STRIPE_FAKE_STORE_PATH=' . sys_get_temp_dir() . '/truckwash-stripe-api-tests.json');
putenv('EMAIL_FAKE_MODE=1');
usesApiSuite();
beforeEach(function (): void {
stripe_fake_http_client::resetStore();
email::resetFakeDeliveries();
if (api_tests_enabled()) {
api_test_runtime()->db()->query("DELETE FROM stripe_module_orders WHERE invoice_id LIKE 'in_fake_%'");
api_test_runtime()->db()->query("DELETE FROM stripe_module_customers WHERE customer_id LIKE 'cus_fake_%' OR email LIKE 'stripe-%@example.com'");
}
});
it('returns a setup required error when department terminal readers are requested without terminal setup', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Stripe Setup Pending Department',
@@ -29,6 +47,248 @@ it('returns a setup required error when department terminal readers are requeste
->toHaveKey('code', 'stripe_terminal_setup_required');
});
it('sends a Stripe invoice by email and persists the hosted invoice association', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Stripe Email Payments Department',
]);
$customer = api_fixtures()->createUser([
'display_name' => 'Stripe Hosted Invoice Customer',
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'STRIPE-EMAIL-SEND',
'reg_1' => 'EMAIL01',
]);
$session = api_fixtures()->createUserSession([
'modules_stripe_invoice_send',
]);
$emailAddress = sprintf('stripe-email-%d@example.com', (int)$order['id']);
$response = api_client()->post('/modules/stripe/invoice', [
'email' => $emailAddress,
'order_id' => $order['id'],
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess(true);
expect($response->data())
->toBeArray()
->toHaveKey('id')
->toHaveKey('hosted_invoice_url')
->toHaveKey('metadata');
expect($response->data()['metadata'] ?? [])
->toMatchArray([
'order_id' => (string)$order['id'],
'customer_id' => (string)$customer['customer_number'],
'department_id' => (string)$department['id'],
'reference' => 'STRIPE-EMAIL-SEND',
'reg_1' => 'EMAIL01',
])
->and(($response->data()['metadata']['stripe_customer_id'] ?? null))
->toBe((string)($response->data()['customer'] ?? ''));
$retrievedInvoice = (new stripe())->invoice->retrieve((string)$response->data()['id']);
$retrievedMetadata = json_decode(json_encode($retrievedInvoice->metadata), true);
if (!is_array($retrievedMetadata)) {
$retrievedMetadata = [];
}
expect($retrievedMetadata)
->toMatchArray([
'order_id' => (string)$order['id'],
'customer_id' => (string)$customer['customer_number'],
'department_id' => (string)$department['id'],
'reference' => 'STRIPE-EMAIL-SEND',
'reg_1' => 'EMAIL01',
])
->and(($retrievedMetadata['stripe_customer_id'] ?? null))
->toBe((string)($response->data()['customer'] ?? ''));
$stored = api_test_runtime()->queryOne(
'SELECT invoice_id, customer_id, url FROM stripe_module_orders WHERE id = ' . (int)$order['id']
);
expect($stored)
->not->toBeNull()
->and($stored['invoice_id'] ?? null)->toBe((string)$response->data()['id'])
->and($stored['customer_id'] ?? null)->not->toBe('')
->and($stored['url'] ?? null)->toBe((string)$response->data()['hosted_invoice_url']);
});
it('returns a conflict when a Stripe hosted invoice is already active for the order', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Stripe Email Guard Department',
]);
$customer = api_fixtures()->createUser([
'display_name' => 'Stripe Duplicate Guard Customer',
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'STRIPE-EMAIL-GUARD',
'reg_1' => 'EMAIL02',
]);
$session = api_fixtures()->createUserSession([
'modules_stripe_invoice_send',
]);
$emailAddress = sprintf('stripe-duplicate-%d@example.com', (int)$order['id']);
api_client()->post('/modules/stripe/invoice', [
'email' => $emailAddress,
'order_id' => $order['id'],
], $session['headers'])->assertStatus(200);
$response = api_client()->post('/modules/stripe/invoice', [
'email' => $emailAddress,
'order_id' => $order['id'],
], $session['headers']);
$response
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('A Stripe payment link is already active for this order.');
expect($response->data())
->toBeArray()
->toHaveKey('code', 'stripe_invoice_exists');
});
it('allows sending a new Stripe hosted invoice when the existing association is already terminal', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Stripe Email Terminal Guard Department',
]);
$customer = api_fixtures()->createUser([
'display_name' => 'Stripe Terminal Guard Customer',
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'STRIPE-EMAIL-TERMINAL',
'reg_1' => 'EMAIL05',
]);
$session = api_fixtures()->createUserSession([
'modules_stripe_invoice_send',
]);
$emailAddress = sprintf('stripe-terminal-%d@example.com', (int)$order['id']);
$firstResponse = api_client()->post('/modules/stripe/invoice', [
'email' => $emailAddress,
'order_id' => $order['id'],
], $session['headers']);
$firstInvoiceId = (string)($firstResponse->data()['id'] ?? '');
stripe_fake_http_client::setInvoiceState($firstInvoiceId, [
'status' => 'void',
'paid' => false,
]);
$secondResponse = api_client()->post('/modules/stripe/invoice', [
'email' => $emailAddress,
'order_id' => $order['id'],
], $session['headers']);
$secondResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess(true);
expect((string)($secondResponse->data()['id'] ?? ''))
->not->toBe('')
->not->toBe($firstInvoiceId);
});
it('voids an unpaid Stripe hosted invoice and clears the local order association', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Stripe Email Cancel Department',
]);
$customer = api_fixtures()->createUser([
'display_name' => 'Stripe Cancel Customer',
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'STRIPE-EMAIL-CANCEL',
'reg_1' => 'EMAIL03',
]);
$session = api_fixtures()->createUserSession([
'modules_stripe_invoice_send',
]);
$emailAddress = sprintf('stripe-cancel-%d@example.com', (int)$order['id']);
$sendResponse = api_client()->post('/modules/stripe/invoice', [
'email' => $emailAddress,
'order_id' => $order['id'],
], $session['headers']);
$invoiceId = (string)($sendResponse->data()['id'] ?? '');
$response = api_client()->delete('/modules/stripe/invoice', [
'order_id' => $order['id'],
], $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess(true);
expect(api_test_runtime()->queryOne(
'SELECT invoice_id FROM stripe_module_orders WHERE id = ' . (int)$order['id']
))->toBeNull();
expect((new stripe())->invoice->retrieve($invoiceId)->status)->toBe('void');
});
it('refuses to cancel a paid Stripe hosted invoice', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Stripe Email Paid Department',
]);
$customer = api_fixtures()->createUser([
'display_name' => 'Stripe Paid Customer',
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'department_id' => $department['id'],
'reference' => 'STRIPE-EMAIL-PAID',
'reg_1' => 'EMAIL04',
]);
$session = api_fixtures()->createUserSession([
'modules_stripe_invoice_send',
]);
$emailAddress = sprintf('stripe-paid-%d@example.com', (int)$order['id']);
$sendResponse = api_client()->post('/modules/stripe/invoice', [
'email' => $emailAddress,
'order_id' => $order['id'],
], $session['headers']);
$invoiceId = (string)($sendResponse->data()['id'] ?? '');
stripe_fake_http_client::setInvoiceState($invoiceId, [
'status' => 'paid',
'paid' => true,
'amount_due' => 0,
'amount_paid' => 1000,
]);
$response = api_client()->delete('/modules/stripe/invoice', [
'order_id' => $order['id'],
], $session['headers']);
$response
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('A paid Stripe payment link cannot be cancelled.');
expect(api_test_runtime()->queryOne(
'SELECT invoice_id FROM stripe_module_orders WHERE id = ' . (int)$order['id']
))->not->toBeNull();
});
it('returns a setup required error when creating a payment intent for a department without terminal setup', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Stripe Payment Intent Pending Department',
@@ -18,6 +18,60 @@ function app_require(string $relative): void
require_once app_path($relative);
}
spl_autoload_register(function (string $class): void {
$class = ltrim($class, '\\');
if ($class === '') {
return;
}
$parts = explode('\\', $class);
$top = strtolower($parts[0] ?? '');
$relative = implode(DIRECTORY_SEPARATOR, array_slice($parts, 1));
if ($relative === '') {
return;
}
$base = app_path();
$candidates = [];
if (in_array($top, ['classes', 'interfaces', 'traits', 'objects', 'routes', 'statistics'], true)) {
$candidates[] = $base . DIRECTORY_SEPARATOR . $top . DIRECTORY_SEPARATOR . $relative;
} elseif ($top === 'modules') {
$candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . $relative;
} else {
$candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $class);
$modules_root = $base . DIRECTORY_SEPARATOR . 'modules';
if (is_dir($modules_root)) {
$module_dirs = array_filter(scandir($modules_root) ?: [], static function (string $entry) use ($modules_root): bool {
return $entry !== '.' && $entry !== '..' && is_dir($modules_root . DIRECTORY_SEPARATOR . $entry);
});
foreach ($module_dirs as $module_dir) {
$candidates[] = $modules_root . DIRECTORY_SEPARATOR . $module_dir . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $class);
}
}
}
foreach ($candidates as $path) {
foreach (['', '_t', '_o', '_s', '_i', '_c', '_m'] as $suffix) {
$file = $path . $suffix . '.php';
if (!is_file($file)) {
continue;
}
require_once $file;
if (
class_exists($class, false)
|| interface_exists($class, false)
|| trait_exists($class, false)
|| (function_exists('enum_exists') && enum_exists($class, false))
) {
return;
}
}
}
});
function integration_enabled(): bool
{
return getenv('RUN_INTEGRATION_TESTS') === '1';
@@ -0,0 +1,119 @@
<?php
app_require('classes/order_bookings_counts_cache.php');
use classes\order_bookings_counts_cache;
if (!class_exists('OrderBookingsCountsCacheRedisFake')) {
class OrderBookingsCountsCacheRedisFake
{
/** @var array<string,string> */
public array $store = [];
public function get(string $key): ?string
{
return $this->store[$key] ?? null;
}
public function setEx(string $key, string $value, int $ttl): void
{
$this->store[$key] = $value;
}
public function clear_keys(string $pattern): void
{
$regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/';
foreach (array_keys($this->store) as $key) {
if (preg_match($regex, $key) === 1) {
unset($this->store[$key]);
}
}
}
}
}
beforeEach(function (): void {
$this->oldTtl = getenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL');
$this->redis = new OrderBookingsCountsCacheRedisFake();
order_bookings_counts_cache::setAdapterForTests($this->redis);
});
afterEach(function (): void {
order_bookings_counts_cache::setAdapterForTests(null);
if ($this->oldTtl === false) {
putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL');
return;
}
putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL=' . $this->oldTtl);
});
it('uses sane ttl defaults and clamps negative ttl to zero', function (): void {
putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL');
expect(order_bookings_counts_cache::getTtl())->toBe(30);
putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL=20');
expect(order_bookings_counts_cache::getTtl())->toBe(20);
putenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL=-1');
expect(order_bookings_counts_cache::getTtl())->toBe(0);
});
it('builds deterministic keys for equivalent count contexts', function (): void {
$keyA = order_bookings_counts_cache::buildKey([
'route' => '/order-bookings/counts',
'day' => '2026-04-21',
'department_ids' => ['3', '1'],
'customer_number' => null,
]);
$keyB = order_bookings_counts_cache::buildKey([
'customer_number' => null,
'route' => '/order-bookings/counts',
'department_ids' => [1, 3],
'day' => '2026-04-21',
]);
expect($keyA)->toBe($keyB);
expect($keyA)->toStartWith(order_bookings_counts_cache::PREFIX);
});
it('stores and retrieves normalized booking counts', function (): void {
$key = order_bookings_counts_cache::buildKey([
'route' => '/order-bookings/counts',
'day' => '2026-04-21',
'department_ids' => [12],
]);
order_bookings_counts_cache::storeCounts($key, [
'past' => '2',
'current' => 3,
'future' => 1,
], 60);
expect(order_bookings_counts_cache::getCounts($key))->toEqual([
'past' => 2,
'current' => 3,
'future' => 1,
]);
});
it('ignores malformed payloads and clears order-bookings count caches', function (): void {
$validKey = order_bookings_counts_cache::buildKey([
'route' => '/order-bookings/counts',
'day' => '2026-04-21',
]);
$this->redis->store[$validKey] = '{"past":1,"current":2,"future":3}';
$this->redis->store[order_bookings_counts_cache::PREFIX . 'broken'] = '{"count":5}';
$this->redis->store['other:key'] = '{"keep":true}';
expect(order_bookings_counts_cache::getCounts(order_bookings_counts_cache::PREFIX . 'broken'))->toBeNull();
order_bookings_counts_cache::clearAll();
expect($this->redis->store)->toHaveKey('other:key');
expect($this->redis->store)->not->toHaveKey($validKey);
expect($this->redis->store)->not->toHaveKey(order_bookings_counts_cache::PREFIX . 'broken');
});
@@ -0,0 +1,23 @@
<?php
it('adds a cached order-bookings counts endpoint and invalidates it on booking changes', function (): void {
$routeFile = app_path('routes/orderBookingRoute.php');
$objectFile = app_path('objects/order_bookings_o.php');
expect(is_file($routeFile))->toBeTrue();
expect(is_file($objectFile))->toBeTrue();
$routeCode = preg_replace('/\s+/', ' ', (string)file_get_contents($routeFile));
$objectCode = preg_replace('/\s+/', ' ', (string)file_get_contents($objectFile));
expect($routeCode)->toContain('use classes\order_bookings_counts_cache;');
expect($routeCode)->toContain("\$this->get('/order-bookings/counts', function () {");
expect($routeCode)->toContain('$cacheTtl = order_bookings_counts_cache::getTtl();');
expect($routeCode)->toContain('$cacheKey = $cacheTtl > 0 ? $this->getOrderBookingsCountsCacheKey(');
expect($routeCode)->toContain('$cachedCounts = order_bookings_counts_cache::getCounts($cacheKey);');
expect($routeCode)->toContain('order_bookings_counts_cache::storeCounts($cacheKey, $counts, $cacheTtl);');
expect($routeCode)->toContain('getPendingBookingCounts(');
expect($objectCode)->toContain('use classes\order_bookings_counts_cache;');
expect($objectCode)->toContain('order_bookings_counts_cache::clearAll();');
});
@@ -0,0 +1,143 @@
<?php
app_require('classes/order_bookings_list_cache.php');
use classes\order_bookings_list_cache;
if (!class_exists('OrderBookingsListCacheRedisFake')) {
class OrderBookingsListCacheRedisFake
{
/** @var array<string,string> */
public array $store = [];
public function reset(): void
{
$this->store = [];
}
public function get(string $key): ?string
{
return $this->store[$key] ?? null;
}
public function setEx(string $key, string $value, int $ttl): void
{
$this->store[$key] = $value;
}
public function clear_keys(string $pattern): void
{
$regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/';
foreach (array_keys($this->store) as $key) {
if (preg_match($regex, $key) === 1) {
unset($this->store[$key]);
}
}
}
}
}
beforeEach(function (): void {
$this->oldTtl = getenv('ORDER_BOOKINGS_LIST_CACHE_TTL');
$this->redis = new OrderBookingsListCacheRedisFake();
order_bookings_list_cache::setAdapterForTests($this->redis);
});
afterEach(function (): void {
order_bookings_list_cache::setAdapterForTests(null);
if ($this->oldTtl === false) {
putenv('ORDER_BOOKINGS_LIST_CACHE_TTL');
return;
}
putenv('ORDER_BOOKINGS_LIST_CACHE_TTL=' . $this->oldTtl);
});
it('uses sane ttl defaults and clamps negative ttl to zero', function (): void {
putenv('ORDER_BOOKINGS_LIST_CACHE_TTL');
expect(order_bookings_list_cache::getTtl())->toBe(30);
putenv('ORDER_BOOKINGS_LIST_CACHE_TTL=45');
expect(order_bookings_list_cache::getTtl())->toBe(45);
putenv('ORDER_BOOKINGS_LIST_CACHE_TTL=-10');
expect(order_bookings_list_cache::getTtl())->toBe(0);
});
it('builds deterministic keys for equivalent contexts', function (): void {
$keyA = order_bookings_list_cache::buildKey([
'route' => '/order-bookings',
'page' => 1,
'limit' => 100,
'search' => '',
'order' => ['datetime' => 'DESC'],
'filters' => [
'customer_number' => '42',
'department' => ['3', '1'],
'order_id' => null,
],
]);
$keyB = order_bookings_list_cache::buildKey([
'search' => '',
'route' => '/order-bookings',
'limit' => 100,
'filters' => [
'order_id' => null,
'department' => [1, 3],
'customer_number' => 42,
],
'order' => ['datetime' => 'DESC'],
'page' => 1,
]);
expect($keyA)->toBe($keyB);
expect($keyA)->toStartWith(order_bookings_list_cache::PREFIX);
});
it('stores and retrieves full order-bookings payloads', function (): void {
$key = order_bookings_list_cache::buildKey([
'route' => '/order-bookings',
'page' => 1,
'limit' => 100,
]);
$payload = [
'success' => true,
'data' => [
['id' => 123],
],
'meta' => [
'pagination' => [
'page' => 1,
'per_page' => 100,
'total' => 1,
],
],
'includes' => [],
];
order_bookings_list_cache::storePayload($key, $payload, 60);
expect(order_bookings_list_cache::getPayload($key))->toBe($payload);
});
it('ignores malformed cached payloads and clears order-bookings list caches', function (): void {
$validKey = order_bookings_list_cache::buildKey([
'route' => '/order-bookings',
'page' => 1,
]);
$this->redis->store[$validKey] = '{"success":true,"data":[]}';
$this->redis->store[order_bookings_list_cache::PREFIX . 'broken'] = '{"not":"a payload"}';
$this->redis->store['other:cache:key'] = '{"leave":"me"}';
expect(order_bookings_list_cache::getPayload(order_bookings_list_cache::PREFIX . 'broken'))->toBeNull();
order_bookings_list_cache::clearAll();
expect($this->redis->store)->toHaveKey('other:cache:key');
expect($this->redis->store)->not->toHaveKey($validKey);
expect($this->redis->store)->not->toHaveKey(order_bookings_list_cache::PREFIX . 'broken');
});
@@ -0,0 +1,24 @@
<?php
it('caches the paginated order-bookings list response and invalidates it on booking changes', function (): void {
$routeFile = app_path('routes/orderBookingRoute.php');
$objectFile = app_path('objects/order_bookings_o.php');
expect(is_file($routeFile))->toBeTrue();
expect(is_file($objectFile))->toBeTrue();
$routeCode = preg_replace('/\s+/', ' ', (string)file_get_contents($routeFile));
$objectCode = preg_replace('/\s+/', ' ', (string)file_get_contents($objectFile));
expect($routeCode)->toContain('use classes\order_bookings_list_cache;');
expect($routeCode)->toContain('$cacheTtl = order_bookings_list_cache::getTtl();');
expect($routeCode)->toContain('$cacheKey = $cacheTtl > 0 ? $this->getOrderBookingsListCacheKey($object, $forcedFilters) : null;');
expect($routeCode)->toContain('$cachedPayload = order_bookings_list_cache::getPayload($cacheKey);');
expect($routeCode)->toContain('$response->rawJson($cachedPayload);');
expect($routeCode)->toContain('order_bookings_list_cache::storePayload($cacheKey, $payload, $cacheTtl);');
expect($routeCode)->toContain('$response->rawJson($payload);');
expect($routeCode)->toContain('private function getOrderBookingsListCacheKey(order_bookings_o $object, string $forcedFilters): string');
expect($objectCode)->toContain('use classes\order_bookings_list_cache;');
expect($objectCode)->toContain('order_bookings_list_cache::clearAll();');
});
@@ -217,8 +217,8 @@ it('builds the overview payload from batched repository data with deterministic
(object)[
'departmentID' => 'dep_1',
'start' => '2026-03-23T09:00:00+00:00',
'end' => '2026-03-23T17:00:00+00:00',
'approval' => (object)['originalEnd' => '2026-03-23T17:30:00+00:00'],
'end' => '2026-03-23T17:30:00+00:00',
'approval' => (object)['originalEnd' => '2026-03-23T17:00:00+00:00'],
],
(object)[
'departmentID' => 'dep_2',
@@ -299,13 +299,44 @@ it('limits overtime counting to the selected reporting range', function (): void
$hours = department_daily_reports_route_invoke_private($route, 'calculateShiftOvertimeHoursInRange', [[
'start' => '2026-03-22T20:00:00+00:00',
'end' => '2026-03-22T23:45:00+00:00',
'approval' => (object)['originalEnd' => '2026-03-23T00:30:00+00:00'],
'end' => '2026-03-23T00:30:00+00:00',
'approval' => (object)['originalEnd' => '2026-03-22T23:45:00+00:00'],
], new \DateTime('2026-03-23T00:00:00+00:00'), new \DateTime('2026-03-24T00:00:00+00:00')]);
expect($hours)->toBe(0.5);
});
it('does not count negative approved overtime when a saved end shortens the shift', function (): void {
$route = new DepartmentDailyReportsOverviewRouteDouble();
$route->repository = new FakeDailyReportRepository();
$route->complaints_repository = new FakeDailyReportComplaintsRepository();
$route->outside_hours_service = new FakeOutsideHoursStatisticsService();
$hours = department_daily_reports_route_invoke_private($route, 'calculateShiftOvertimeHoursInRange', [[
'start' => '2026-03-23T10:00:00+00:00',
'end' => '2026-03-23T17:00:00+00:00',
'approval' => (object)['originalEnd' => '2026-03-23T18:00:00+00:00'],
], new \DateTime('2026-03-23T00:00:00+00:00'), new \DateTime('2026-03-24T00:00:00+00:00')]);
expect($hours)->toBe(0.0);
});
it('ignores late unapproved administrative edits when calculating overtime', function (): void {
$route = new DepartmentDailyReportsOverviewRouteDouble();
$route->repository = new FakeDailyReportRepository();
$route->complaints_repository = new FakeDailyReportComplaintsRepository();
$route->outside_hours_service = new FakeOutsideHoursStatisticsService();
$hours = department_daily_reports_route_invoke_private($route, 'calculateShiftOvertimeHoursInRange', [[
'start' => '2026-03-23T09:00:00+00:00',
'end' => '2026-03-23T17:00:00+00:00',
'approval' => null,
'updateTime' => '2026-03-24T02:30:00+00:00',
], new \DateTime('2026-03-23T00:00:00+00:00'), new \DateTime('2026-03-24T00:00:00+00:00')]);
expect($hours)->toBe(0.0);
});
it('wires the overview route to batched repository methods and overview path', function (): void {
$routeContent = (string)file_get_contents(app_path('routes/departmentDailyReportsRoute.php'));
$objectContent = (string)file_get_contents(app_path('objects/department_daily_reports_o.php'));
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
function economic_draft_customer_openapi_specs_or_skip(): array
{
$root = dirname(__DIR__, 7);
$candidates = [
$root . DIRECTORY_SEPARATOR . 'backend-php' . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'nginx' . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'openapi.yaml',
$root . DIRECTORY_SEPARATOR . 'backend-php' . DIRECTORY_SEPARATOR . 'openapi.yaml',
$root . DIRECTORY_SEPARATOR . 'front-end-vue' . DIRECTORY_SEPARATOR . 'openapi.yaml',
];
$specs = [];
foreach ($candidates as $candidate) {
if (!is_file($candidate)) {
test()->markTestSkipped('One or more tracked openapi.yaml copies are not available in this runtime environment.');
}
$content = file_get_contents($candidate);
if ($content === false) {
test()->markTestSkipped('Failed to read one or more tracked openapi.yaml copies.');
}
$specs[$candidate] = $content;
}
return $specs;
}
it('documents transaction draft customer config and auth runtime fields in all tracked openapi copies', function (): void {
foreach (economic_draft_customer_openapi_specs_or_skip() as $path => $content) {
expect($content, $path)->toContain('/auth/session:');
expect($content, $path)->toContain('transaction_draft_customer_number:');
expect($content, $path)->toContain('EconomicConfigEntry:');
expect($content, $path)->toContain('transactionDraftCustomerNumber');
expect($content, $path)->toContain('nullable: true');
}
});
@@ -0,0 +1,176 @@
<?php
app_require('classes/edge_gateway_agent_artifact_locator.php');
app_require('classes/edge_gateway_install_service.php');
app_require('classes/edge_gateway_manager.php');
use classes\edge_gateway_agent_artifact_locator;
use classes\edge_gateway_install_service;
use classes\edge_gateway_manager;
class EdgeGatewayInstallServiceHarness extends edge_gateway_install_service
{
public function __construct()
{
}
}
class EdgeGatewayManagerArtifactHarness extends edge_gateway_manager
{
public function __construct()
{
}
}
function with_edge_gateway_artifact_env(array $values, callable $callback): void
{
$keys = ['EDGE_AGENT_ARTIFACT_DIR'];
$originals = [];
foreach ($keys as $key) {
$originals[$key] = getenv($key);
}
try {
foreach ($keys as $key) {
if (array_key_exists($key, $values) && $values[$key] !== null) {
putenv($key . '=' . $values[$key]);
continue;
}
putenv($key);
}
$callback();
} finally {
foreach ($originals as $key => $value) {
if ($value === false) {
putenv($key);
continue;
}
putenv($key . '=' . $value);
}
}
}
function remove_edge_gateway_temp_path(string $path): void
{
if (is_file($path)) {
unlink($path);
return;
}
if (is_dir($path)) {
rmdir($path);
}
}
it('resolves edge-agent artifacts from a supported runtime layout', function (): void {
$path = edge_gateway_agent_artifact_locator::resolve('agent.php', app_path());
expect(str_replace('\\', '/', $path))->toEndWith('/resources/edge-gateway-agent/agent.php');
expect(is_file($path))->toBeTrue();
});
it('prioritizes router resources before mounted and baked-in artifact directories', function (): void {
with_edge_gateway_artifact_env(['EDGE_AGENT_ARTIFACT_DIR' => null], function (): void {
$candidatePaths = array_map(
static fn(string $path): string => str_replace('\\', '/', $path),
edge_gateway_agent_artifact_locator::candidatePaths(
'agent.php',
'/var/www/html',
'/services/edge-agent/php-agent',
'/opt/truckwash-edge-agent-artifacts'
)
);
expect(array_slice($candidatePaths, 0, 3))->toBe([
'/var/www/html/resources/edge-gateway-agent/agent.php',
'/services/edge-agent/php-agent/agent.php',
'/opt/truckwash-edge-agent-artifacts/agent.php',
]);
expect($candidatePaths)->toContain('/var/edge-agent/php-agent/agent.php');
expect($candidatePaths)->toContain('/var/www/edge-agent/php-agent/agent.php');
});
});
it('reads install artifacts through the shared locator', function (): void {
$service = new EdgeGatewayInstallServiceHarness();
$contents = $service->readArtifact('truckwash-edge-agent.service');
expect($contents)->toContain('ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php');
});
it('builds update payloads with checksums from resolved artifact paths', function (): void {
$manager = new EdgeGatewayManagerArtifactHarness();
$payload = $manager->buildUpdateOperationRequest('2.0.0', 'stable');
expect($payload['artifactSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/agent.php')));
expect($payload['serviceUnitSha256'])->toBe(hash_file('sha256', app_path('resources/edge-gateway-agent/truckwash-edge-agent.service')));
});
it('falls back to baked-in artifacts when the mount path is absent', function (): void {
$tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'edge-gateway-baked-' . bin2hex(random_bytes(6));
$appPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'nginx' . DIRECTORY_SEPARATOR . 'app';
$missingMountPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent';
$bakedPath = $tempRoot . DIRECTORY_SEPARATOR . 'baked-artifacts';
$bakedAgentPath = $bakedPath . DIRECTORY_SEPARATOR . 'agent.php';
@mkdir($appPath, 0777, true);
@mkdir($bakedPath, 0777, true);
file_put_contents($bakedAgentPath, "<?php\n// baked artifact\n");
try {
$resolvedPath = edge_gateway_agent_artifact_locator::resolve(
'agent.php',
$appPath,
$missingMountPath,
$bakedPath
);
expect(str_replace('\\', '/', $resolvedPath))->toBe(str_replace('\\', '/', $bakedAgentPath));
} finally {
remove_edge_gateway_temp_path($bakedAgentPath);
foreach ([
$bakedPath,
$appPath,
dirname($appPath),
dirname(dirname($appPath)),
dirname($missingMountPath),
dirname(dirname($missingMountPath)),
$tempRoot,
] as $directory) {
remove_edge_gateway_temp_path($directory);
}
}
});
it('explains the legacy dist mount mismatch when php artifacts are unavailable', function (): void {
$tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'edge-gateway-artifacts-' . bin2hex(random_bytes(6));
$appPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'nginx' . DIRECTORY_SEPARATOR . 'app';
$legacyDistPath = $tempRoot . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'dist';
@mkdir($appPath, 0777, true);
@mkdir($legacyDistPath, 0777, true);
file_put_contents($legacyDistPath . DIRECTORY_SEPARATOR . 'agent.mjs', '// legacy node artifact');
try {
edge_gateway_agent_artifact_locator::resolve('agent.php', $appPath);
test()->fail('Expected artifact resolution to fail when php-agent artifacts are missing.');
} catch (Exception $exception) {
expect($exception->getMessage())->toContain('Missing edge agent artifact: agent.php.');
expect($exception->getMessage())->toContain('Deploy the router-owned artifacts under');
expect($exception->getMessage())->toContain('Legacy Node dist artifact found at');
expect($exception->getMessage())->toContain('Remove /services/edge-agent/dist and deploy the router resources instead of depending on the legacy mount.');
} finally {
remove_edge_gateway_temp_path($legacyDistPath . DIRECTORY_SEPARATOR . 'agent.mjs');
foreach ([
$legacyDistPath,
dirname($legacyDistPath),
$appPath,
dirname($appPath),
dirname(dirname($appPath)),
$tempRoot,
] as $directory) {
remove_edge_gateway_temp_path($directory);
}
}
});
@@ -0,0 +1,91 @@
<?php
app_require('classes/edge_gateway_manager.php');
use classes\edge_gateway_manager;
it('summarizes fleet usage statistics for the dashboard landing view', function (): void {
$summary = edge_gateway_manager::summarizeFleetUsage([
[
'id' => 701,
'department_id' => 1,
'status' => edge_gateway_manager::STATUS_ONLINE,
'version_drift' => ['is_drifted' => true],
'channel_status' => ['broker' => ['connected' => true]],
'active_operation' => ['id' => 91, 'type' => 'DISCOVERY'],
'recent_operations_summary' => ['pending' => 1, 'in_progress' => 1],
'backlog_depth' => ['operations' => 2, 'commands' => 3],
'metadata' => [
'system_metrics' => [
'latency_ms' => 184,
'cpu_usage_pct' => 27,
'memory_usage_pct' => 61,
'disk_usage_pct' => 58,
],
],
],
[
'id' => 702,
'department_id' => 2,
'status' => edge_gateway_manager::STATUS_OFFLINE,
'version_drift' => ['is_drifted' => false],
'channel_status' => ['broker' => ['connected' => false]],
'active_operation' => null,
'recent_operations_summary' => ['pending' => 0, 'in_progress' => 0],
'backlog_depth' => ['operations' => 0, 'commands' => 1],
'metadata' => [
'system_metrics' => [
'latency_ms' => 412,
'cpu_usage_pct' => 9,
'memory_usage_pct' => 42,
'disk_usage_pct' => 76,
],
],
],
], [
'total' => 2,
'online' => 1,
'offline' => 1,
], [
'total' => 3,
'fallback_overrides' => 2,
'cloud_only' => 1,
'local_only' => 1,
]);
expect($summary['gateways'])->toBe([
'total' => 2,
'departments' => 2,
'online' => 1,
'offline' => 1,
'degraded' => 0,
'drifted' => 1,
'broker_connected' => 1,
]);
expect($summary['inventory'])->toBe([
'total' => 2,
'online' => 1,
'offline' => 1,
]);
expect($summary['bindings'])->toBe([
'total' => 3,
'fallback_overrides' => 2,
'cloud_only' => 1,
'local_only' => 1,
]);
expect($summary['operations'])->toBe([
'active' => 1,
'pending' => 1,
'in_progress' => 1,
'backlog' => 2,
]);
expect($summary['commands'])->toBe([
'backlog' => 4,
]);
expect($summary['system'])->toBe([
'latency_ms_avg' => 298,
'cpu_usage_pct_avg' => 18,
'memory_usage_pct_avg' => 52,
'disk_usage_pct_avg' => 67,
]);
});
@@ -4,50 +4,45 @@ app_require('classes/edge_gateway_manager.php');
use classes\edge_gateway_manager;
it('queues admin commands, exposes agent poll/result handlers, and keeps heartbeats from mutating discovery state', function (): void {
$source = file_get_contents(app_path('classes/edge_gateway_manager.php'));
it('keeps relay dispatch and discovery queueing on the edge gateway manager', function (): void {
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
$operationServiceSource = file_get_contents(app_path('classes/edge_gateway_operation_service.php'));
expect($source)->not->toBeFalse();
expect($source)->toContain('public function pollCommand');
expect($source)->toContain('public function submitCommandResult');
expect($source)->toContain('private function waitForCommandResult');
expect($source)->toContain('private function claimNextCommandJob');
expect($source)->toContain("COALESCE(updated_at, created_at, requested_at) <= :stale_before");
expect($source)->toContain("throw new Exception('Edge gateway command timed out');");
expect($source)->toContain("throw new Exception('Gateway agent is offline');");
expect($source)->toContain("\$gateway->discovery_status->set('PENDING');");
expect($source)->toContain("\$gateway->discovery_status->set('READY');");
expect($source)->toContain("\$gateway->discovery_status->set('FAILED');");
expect($source)->toContain("\$this->buildUpdateCommandPayload(\$targetVersion, \$releaseChannel)");
expect($source)->toContain("'UNINSTALL_AGENT'");
expect($source)->toContain("public function queueUninstall");
expect($source)->toContain("public function deleteGateway");
expect($source)->toContain("private function softDeleteGatewayRelations");
expect($source)->toContain("\$updateJob->status->set('DISPATCHING');");
expect($source)->toContain("\$updateJob->status->set('VERIFYING');");
expect($source)->toContain("\$updateJob->status->set(\$finalStatus ?? (\$ok ? 'COMPLETED' : 'FAILED'));");
expect($source)->toContain("\$this->applyHeartbeatUpdateLifecycle(\$gateway, \$payload);");
expect($source)->not->toContain("\$gateway->discovery_status->set((string)(\$payload['discovery_status'] ?? \$gateway->discovery_status->value()));");
expect($managerSource)->not->toBeFalse();
expect($managerSource)->toContain('public function queueDiscovery');
expect($managerSource)->toContain('public function dispatchRelayStatus');
expect($managerSource)->toContain('public function dispatchRelaySwitch');
expect($managerSource)->toContain('private function createCommandJob');
expect($managerSource)->toContain('public function buildUpdateOperationRequest');
expect($managerSource)->toContain('public function rotateGatewayCredentials');
expect($operationServiceSource)->toContain('public function queueOperation');
expect($operationServiceSource)->toContain('public function claimNextOperation');
expect($operationServiceSource)->toContain('public function completeAgentOperation');
expect($operationServiceSource)->toContain('public const OPERATION_LEASE_SECONDS = 45;');
expect($operationServiceSource)->toContain('private function refreshOperationLease');
expect($operationServiceSource)->toContain('agent_instance_id');
expect($operationServiceSource)->toContain('lease_expires_at');
expect($operationServiceSource)->toContain("'operation_type' => \$type");
expect($managerSource)->toContain("command_type");
});
it('defines the dispatchable gateway guard and api-polled shell queue on the loaded manager class', function (): void {
$reflection = new \ReflectionClass(edge_gateway_manager::class);
it('loads relay command helpers on the manager and gateway operations on the dedicated service', function (): void {
$reflection = new ReflectionClass(edge_gateway_manager::class);
$operationServiceReflection = new ReflectionClass(\classes\edge_gateway_operation_service::class);
expect($reflection->hasMethod('requireDispatchableGateway'))->toBeTrue();
expect($reflection->getMethod('requireDispatchableGateway')->isPrivate())->toBeTrue();
expect($reflection->hasMethod('queueDiscovery'))->toBeTrue();
expect($reflection->hasMethod('queueUpdate'))->toBeTrue();
expect($reflection->hasMethod('queueUninstall'))->toBeTrue();
expect($reflection->hasMethod('deleteGateway'))->toBeTrue();
expect($reflection->hasMethod('buildUpdateCommandPayload'))->toBeTrue();
expect($reflection->hasMethod('applyHeartbeatUpdateLifecycle'))->toBeTrue();
expect($reflection->hasMethod('pollCommand'))->toBeTrue();
expect($reflection->hasMethod('submitCommandResult'))->toBeTrue();
expect($reflection->hasMethod('dispatchRelayStatus'))->toBeTrue();
expect($reflection->hasMethod('dispatchRelaySwitch'))->toBeTrue();
expect($reflection->hasMethod('pollShellAction'))->toBeTrue();
expect($reflection->hasMethod('submitShellActionResult'))->toBeTrue();
expect($reflection->hasMethod('recordShellSessionEvents'))->toBeTrue();
expect($reflection->hasMethod('pollShellSessionEvents'))->toBeTrue();
expect($reflection->hasMethod('enqueueShellInput'))->toBeTrue();
expect($reflection->hasMethod('enqueueShellResize'))->toBeTrue();
expect($reflection->hasMethod('requestShellSessionClose'))->toBeTrue();
expect($reflection->hasMethod('claimNextCommandJob'))->toBeTrue();
expect($reflection->getMethod('claimNextCommandJob')->isPrivate())->toBeTrue();
expect($reflection->hasMethod('syncDeviceInventory'))->toBeTrue();
expect($reflection->getMethod('syncDeviceInventory')->isPrivate())->toBeTrue();
expect($reflection->hasMethod('syncGatewayInventory'))->toBeTrue();
expect($operationServiceReflection->hasMethod('queueOperation'))->toBeTrue();
expect($operationServiceReflection->hasMethod('listOperations'))->toBeTrue();
expect($operationServiceReflection->hasMethod('claimNextOperation'))->toBeTrue();
expect($operationServiceReflection->hasMethod('appendAgentOperationEvent'))->toBeTrue();
expect($operationServiceReflection->hasMethod('completeAgentOperation'))->toBeTrue();
});
@@ -68,7 +68,7 @@ it('merges incoming heartbeat metadata with existing gateway metadata', function
expect($source)->toContain("\$gateway->metadata_json->set(array_merge(\$existingMetadata, (array)(\$payload['metadata'] ?? [])));");
});
it('derives relay fallback and transport health details for degraded hybrid control planes', function (): void {
it('derives relay fallback and transport health details without shell or update runtime state', function (): void {
$gateway = edge_gateway_manager::deriveGatewayRuntimeState([
'status' => edge_gateway_manager::STATUS_ONLINE,
'last_heartbeat_at' => '2026-04-08 10:04:30',
@@ -82,8 +82,13 @@ it('derives relay fallback and transport health details for degraded hybrid cont
],
'operational_snapshot' => [
'command_backlog' => 2,
'shell_backlog' => 1,
'update_backlog' => 1,
'operation_backlog' => 1,
],
'active_operation' => [
'id' => 91,
'type' => 'DISCOVERY',
'status' => 'IN_PROGRESS',
'started_at' => '2026-04-08 09:30:00',
],
'bindings' => [
[
@@ -113,15 +118,11 @@ it('derives relay fallback and transport health details for degraded hybrid cont
'completed_at' => '2026-04-08 10:02:00',
],
],
'recent_shell_sessions' => [
[
'opened_at' => '2026-04-08 10:03:00',
],
],
], strtotime('2026-04-08 10:05:00'));
expect($gateway['channel_status']['command']['active'])->toBe(edge_gateway_manager::DELIVERY_CHANNEL_API);
expect($gateway['channel_status']['broker']['state'])->toBe(edge_gateway_manager::STATUS_OFFLINE);
expect($gateway['channel_status'])->not->toHaveKey('shell');
expect($gateway['relay_health'][0]['execution_path'])->toBe('cloud');
expect($gateway['relay_health'][0]['reason'])->toBe('device_stale');
expect($gateway['relay_health'][0]['recommended_action'])->toBe('retry_discovery');
@@ -132,10 +133,10 @@ it('derives relay fallback and transport health details for degraded hybrid cont
expect($gateway['transport_health']['status'])->toBe(edge_gateway_manager::STATUS_DEGRADED);
expect($gateway['transport_health']['recommended_action'])->toBe('retry_discovery');
expect($gateway['last_successful_discovery_at'])->toBe('2026-04-08 10:02:00');
expect($gateway['last_successful_shell_at'])->toBe('2026-04-08 10:03:00');
expect($gateway['diagnostics'])->not->toBeEmpty();
expect($gateway['error_state']['code'])->toBe('EDGE_GATEWAY_OPERATION_TIMEOUT');
expect($gateway['backlog_depth'])->toBe([
'commands' => 2,
'shell_actions' => 1,
'updates' => 1,
'operations' => 1,
]);
});
@@ -15,7 +15,6 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
{
$originalServer = $_SERVER;
$originalPublicApiUrl = getenv('EDGE_PUBLIC_API_URL');
$originalPublicBrokerUrl = getenv('EDGE_PUBLIC_BROKER_URL');
$_SERVER = $server;
@@ -28,15 +27,10 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
} else {
putenv('EDGE_PUBLIC_API_URL=' . $originalPublicApiUrl);
}
if ($originalPublicBrokerUrl === false) {
putenv('EDGE_PUBLIC_BROKER_URL');
} else {
putenv('EDGE_PUBLIC_BROKER_URL=' . $originalPublicBrokerUrl);
}
}
}
it('builds install script urls with forwarded https scheme when proxied', function (): void {
it('builds install script urls with the PHP agent artifacts and forwarded https scheme', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'api.truckwash.io:4433',
'HTTP_X_FORWARDED_PROTO' => 'https',
@@ -48,18 +42,17 @@ it('builds install script urls with forwarded https scheme when proxied', functi
expect($manager->buildInstallTokenVerifyUrl('abc123'))->toBe('https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123');
expect($manager->buildInstallScriptUrl('abc123'))->toBe('https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123');
expect($manager->buildInstallCommand('abc123'))->toContain("https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123");
expect($script)->toContain('curl -fsSL "https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123" >/dev/null');
expect($script)->toContain('mkdir -p "$INSTALL_DIR"');
expect($script)->toContain('curl -fsSL "https://api.truckwash.io:4433/edge-agent/artifacts/package.json" -o "$INSTALL_DIR/package.json"');
expect($script)->toContain('curl -fsSL "https://api.truckwash.io:4433/edge-agent/artifacts/agent.mjs" -o "$INSTALL_DIR/agent.mjs"');
expect($script)->toContain('fetch_http "Verify install token" "https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123"');
expect($script)->toContain('fetch_http "Download PHP edge agent" "https://api.truckwash.io:4433/edge-agent/artifacts/agent.php" "$INSTALL_DIR/agent.php"');
expect($script)->toContain('fetch_http "Download systemd service unit" "https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service" "$INSTALL_DIR/truckwash-edge-agent.service"');
expect($script)->toContain('Installer failed during step: ${CURRENT_STEP:-unknown}');
expect($script)->toContain('Last request: ${CURRENT_METHOD} ${CURRENT_URL}');
expect($script)->toContain('Response body preview (first 400 bytes):');
expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"');
expect($script)->toContain('"installDir":"/opt/truckwash-edge-agent"');
expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"');
expect($script)->toContain('"commandPollTimeoutSeconds":20');
expect($script)->toContain('"shellActionPollTimeoutSeconds":20');
expect($script)->toContain('"updateVerificationTimeoutSeconds":45');
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4300"');
expect($script)->not->toContain('Undefined variable $INSTALL_DIR');
expect($script)->toContain('"operationPollTimeoutSeconds":20');
expect($script)->not->toContain('"shellActionPollTimeoutSeconds"');
expect($script)->not->toContain('"brokerUrl"');
});
});
@@ -75,47 +68,19 @@ it('appends forwarded ports when the forwarded host omits them', function (): vo
});
});
it('infers https for the staging api host when only the https port is present', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'api.truckwash.io:4433',
'SERVER_PORT' => '4433',
], function (): void {
$manager = new EdgeGatewayManagerUrlHarness();
$script = $manager->buildInstallScript('port-only');
expect($manager->getApiBaseUrl())->toBe('https://api.truckwash.io:4433');
expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"');
expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"');
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4300"');
});
});
it('prefers EDGE_PUBLIC_API_URL when explicitly configured', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'localhost',
'HTTP_X_FORWARDED_PROTO' => 'http',
], function (): void {
putenv('EDGE_PUBLIC_API_URL=https://edge.example.test/api');
putenv('EDGE_PUBLIC_BROKER_URL=https://broker.edge.example.test');
$manager = new EdgeGatewayManagerUrlHarness();
$script = $manager->buildInstallScript('token-1');
expect($manager->getApiBaseUrl())->toBe('https://edge.example.test/api');
expect($manager->buildInstallScriptUrl('token-1'))->toBe('https://edge.example.test/api/edge-agent/install.sh?token=token-1');
expect($manager->buildInstallScript('token-1'))->toContain('"brokerUrl":"https://broker.edge.example.test"');
});
});
it('includes node-pty build prerequisites in the generated install script', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'api.truckwash.io:4433',
'HTTP_X_FORWARDED_PROTO' => 'https',
], function (): void {
$manager = new EdgeGatewayManagerUrlHarness();
$script = $manager->buildInstallScript('pty-token');
expect($script)->toMatch('/set -euo pipefail\s+curl -fsSL "https:\/\/api\.truckwash\.io:4433\/edge-agent\/install-token\/verify\?token=pty-token" >\/dev\/null\s+INSTALL_DIR=\/opt\/truckwash-edge-agent/');
expect($script)->toContain('apt-get install -y curl ca-certificates nodejs npm python3 make g++');
expect($script)->toContain('npm install --omit=dev');
expect($script)->toContain('"apiUrl":"https://edge.example.test/api"');
expect($script)->not->toContain('"brokerUrl"');
});
});
@@ -0,0 +1,29 @@
<?php
app_require('classes/edge_gateway_manager.php');
use classes\edge_gateway_manager;
it('adds a gateway metadata update endpoint with label and primary assignment fields', function (): void {
$routeSource = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
expect($routeSource)->not->toBeFalse();
expect($routeSource)->toContain("put('/edge-gateways/{id}'");
expect($routeSource)->toContain("self::requireParameters(['label', 'is_primary']);");
expect($routeSource)->toContain("self::requireType(self::getParameter('label'), self::TYPE_STRING());");
expect($routeSource)->toContain("self::requireType(self::getParameter('is_primary'), self::TYPE_BOOL());");
expect($routeSource)->toContain('updateGatewayMetadata(');
});
it('reassigns department primary gateways through dedicated manager helpers', function (): void {
$source = file_get_contents(app_path('classes/edge_gateway_manager.php'));
$reflection = new ReflectionClass(edge_gateway_manager::class);
expect($source)->not->toBeFalse();
expect($source)->toContain("\$this->setGatewayPrimaryState(\$gateway, true);");
expect($source)->toContain("\$replacement = \$this->findAlternateGatewayForDepartment(");
expect($source)->toContain("throw new Exception('Department must retain a primary gateway');");
expect($reflection->hasMethod('updateGatewayMetadata'))->toBeTrue();
expect($reflection->hasMethod('setGatewayPrimaryState'))->toBeTrue();
expect($reflection->hasMethod('findAlternateGatewayForDepartment'))->toBeTrue();
});
@@ -0,0 +1,17 @@
<?php
it('keeps the legacy operation_type column compatible with v2 operation queueing', function (): void {
$bootstrapContent = file_get_contents(app_path('classes/edge_gateway_schema_bootstrap.php'));
$operationServiceContent = file_get_contents(app_path('classes/edge_gateway_operation_service.php'));
$operationObjectContent = file_get_contents(app_path('objects/edge_gateway_operations_o.php'));
expect($bootstrapContent)->not->toBeFalse();
expect($operationServiceContent)->not->toBeFalse();
expect($operationObjectContent)->not->toBeFalse();
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'operation_type', \"VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER type\")");
expect($bootstrapContent)->toContain('private static function syncOperationTypeColumns(): void');
expect($bootstrapContent)->toContain('SET type = operation_type');
expect($operationServiceContent)->toContain("'operation_type' => \$type");
expect($operationObjectContent)->toContain("new object_property(\$this->table, \$this->id, 'operation_type', 'string', false)");
});
@@ -1,6 +1,6 @@
<?php
it('registers the edge gateway management REST endpoints', function (): void {
it('registers the v2 operator-facing edge gateway routes', function (): void {
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
expect($route)->not->toBeFalse();
@@ -9,42 +9,32 @@ it('registers the edge gateway management REST endpoints', function (): void {
expect($route)->toContain("'/edge-gateways/install-token'");
expect($route)->toContain("'/edge-gateways/{id}/discovery'");
expect($route)->toContain("'/edge-gateways/{id}/bindings'");
expect($route)->toContain("'/edge-gateways/{id}/update-jobs'");
expect($route)->toContain("'/edge-gateways/{id}/uninstall'");
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions'");
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/events'");
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/input'");
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/resize'");
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/close'");
expect($route)->toContain("'/edge-gateways/{id}/operations'");
expect($route)->toContain("'/edge-gateways/{id}/operations/{operationId}/events'");
expect($route)->toContain("'/edge-gateways/{id}/rotate-credentials'");
expect($route)->toContain("'/edge-gateways/{id}'");
expect($route)->toContain("'/departments/{id}/gateway-cutover'");
expect($route)->toContain("add_meta('fleet_usage'");
expect($route)->not->toContain("'/edge-gateways/{id}/shell-sessions'");
expect($route)->not->toContain('private function requirePermission');
expect($route)->not->toContain('private function requireDepartmentAccess');
});
it('registers public installer, claim, heartbeat, command polling, and shell polling endpoints', function (): void {
it('registers PHP edge agent routes for operations and legacy relay command polling', function (): void {
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
expect($route)->toContain("'/edge-agent/install-token/verify'");
expect($route)->toContain("'/edge-agent/install.sh'");
expect($route)->toContain("'/edge-agent/artifacts/agent.mjs'");
expect($route)->toContain("'/edge-agent/artifacts/package.json'");
expect($route)->toContain("'/edge-agent/artifacts/agent.php'");
expect($route)->toContain("'/edge-agent/artifacts/truckwash-edge-agent.service'");
expect($route)->toContain("'/edge-agent/claim'");
expect($route)->toContain("'/edge-agent/gateways/{id}/heartbeat'");
expect($route)->toContain("'/edge-agent/gateways/{id}/operations/next'");
expect($route)->toContain("'/edge-agent/gateways/{id}/operations/{operationId}/events'");
expect($route)->toContain("'/edge-agent/gateways/{id}/operations/{operationId}/complete'");
expect($route)->toContain("'/edge-agent/gateways/{id}/commands/poll'");
expect($route)->toContain("'/edge-agent/gateways/{id}/commands/{jobId}/result'");
expect($route)->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
expect($route)->toContain("'/edge-agent/gateways/{id}/shell-actions/{actionId}/result'");
expect($route)->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
});
it('registers authenticated internal broker validation and presence endpoints', function (): void {
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/validate'");
expect($route)->toContain("'/edge-agent/internal/shell-sessions/validate'");
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/presence'");
expect($route)->toContain("'/edge-agent/internal/shell-sessions/close'");
expect($route)->toContain('HTTP_X_EDGE_BROKER_SECRET');
expect($route)->toContain('EDGE_BROKER_SHARED_SECRET');
expect($route)->toContain("'/edge-agent/gateways/{id}/presence'");
expect($route)->toContain('echo $exception->getMessage()');
expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
});
@@ -1,6 +1,6 @@
<?php
it('defines the edge gateway runtime schema bootstrap tables', function (): void {
it('defines the v2 edge gateway schema bootstrap tables', function (): void {
$bootstrapContent = file_get_contents(app_path('classes/edge_gateway_schema_bootstrap.php'));
expect($bootstrapContent)->not->toBeFalse();
@@ -9,24 +9,38 @@ it('defines the edge gateway runtime schema bootstrap tables', function (): void
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_device_inventory');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_relay_bindings');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_command_jobs');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_update_jobs');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_shell_sessions');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_shell_action_jobs');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_shell_events');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_operations');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_operation_events');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_audit_logs');
expect($bootstrapContent)->not->toContain('edge_gateway_shell_sessions');
expect($bootstrapContent)->not->toContain('edge_gateway_shell_action_jobs');
expect($bootstrapContent)->not->toContain('edge_gateway_shell_events');
});
it('stores edge gateway heartbeats, bindings, shell transcripts, and shell polling queues', function (): void {
it('stores operation metadata and event timelines for management workflows', function (): void {
$bootstrapContent = file_get_contents(app_path('classes/edge_gateway_schema_bootstrap.php'));
expect($bootstrapContent)->toContain('last_heartbeat_at DATETIME NULL');
expect($bootstrapContent)->toContain('command_job_id INT NULL');
expect($bootstrapContent)->toContain('channel INT NOT NULL DEFAULT 0');
expect($bootstrapContent)->toContain('transcript_text LONGTEXT NULL');
expect($bootstrapContent)->toContain('action_type VARCHAR(32) NOT NULL');
expect($bootstrapContent)->toContain('payload_json JSON NULL');
expect($bootstrapContent)->toContain('event_type VARCHAR(32) NOT NULL');
expect($bootstrapContent)->toContain('command_type VARCHAR(64) NOT NULL');
expect($bootstrapContent)->toContain('delivery_json JSON NULL');
expect($bootstrapContent)->toContain("fallback_mode VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL'");
expect($bootstrapContent)->toContain('type VARCHAR(32) NOT NULL');
expect($bootstrapContent)->toContain("operation_type VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY'");
expect($bootstrapContent)->toContain('agent_instance_id VARCHAR(128) NULL');
expect($bootstrapContent)->toContain('lease_expires_at DATETIME NULL');
expect($bootstrapContent)->toContain('last_progress_at DATETIME NULL');
expect($bootstrapContent)->toContain('attempt_count INT NOT NULL DEFAULT 0');
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'type', \"VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id\")");
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'operation_type', \"VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER type\")");
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'summary_json', 'JSON NULL AFTER request_json')");
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'agent_instance_id', 'VARCHAR(128) NULL AFTER correlation_id')");
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'lease_expires_at', 'DATETIME NULL AFTER agent_instance_id')");
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'last_progress_at', 'DATETIME NULL AFTER lease_expires_at')");
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operations', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER last_progress_at')");
expect($bootstrapContent)->toContain('private static function syncOperationTypeColumns(): void');
expect($bootstrapContent)->toContain('SET type = operation_type');
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operation_events', 'context_json', 'JSON NULL AFTER message')");
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_audit_logs', 'context_json', 'JSON NULL AFTER severity')");
expect($bootstrapContent)->toContain('summary_json JSON NULL');
expect($bootstrapContent)->toContain('context_json JSON NULL');
expect(substr_count($bootstrapContent, 'delivery_json JSON NULL'))->toBeGreaterThanOrEqual(3);
expect($bootstrapContent)->toContain('ADD COLUMN delivery_json JSON NULL');
expect($bootstrapContent)->not->toContain('session_token_hash CHAR(64) NOT NULL');
});
@@ -1,36 +1,15 @@
<?php
it('rewrites edge gateway shell transport to API polling queues', function (): void {
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
it('removes shell access from the edge gateway HTTP contracts', function (): void {
$routeSource = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
expect($managerSource)->not->toBeFalse();
expect($routeSource)->not->toBeFalse();
expect($managerSource)->toContain("'transport' => self::DELIVERY_CHANNEL_API");
expect($managerSource)->toContain("'transport_path' => self::DELIVERY_CHANNEL_API");
expect($managerSource)->toContain("'reconnect_state' => 'PENDING'");
expect($managerSource)->toContain("'preferred_channel' => self::DELIVERY_CHANNEL_API");
expect($managerSource)->toContain('private function createShellActionJob');
expect($managerSource)->toContain('private function claimNextShellActionJob');
expect($managerSource)->toContain('private function appendShellEvent');
expect($managerSource)->toContain('private function listShellEvents');
expect($managerSource)->toContain('public function pollShellAction');
expect($managerSource)->toContain('public function submitShellActionResult');
expect($managerSource)->toContain('public function recordShellSessionEvents');
expect($managerSource)->not->toContain('buildBrowserShellWsUrl');
expect($managerSource)->not->toContain('edge_broker_client');
expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/events'");
expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/input'");
expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/resize'");
expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/close'");
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/shell-actions/{actionId}/result'");
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
expect($routeSource)->toContain("'/edge-agent/internal/gateways/{id}/validate'");
expect($routeSource)->toContain("'/edge-agent/internal/shell-sessions/validate'");
expect($routeSource)->toContain("'/edge-agent/internal/gateways/{id}/presence'");
expect($routeSource)->toContain("'/edge-agent/internal/shell-sessions/close'");
expect($routeSource)->toContain('requireBrokerSharedSecret');
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/events'");
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/input'");
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/resize'");
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/close'");
expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/{actionId}/result'");
expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
});
@@ -1,22 +1,43 @@
<?php
it('builds update command payloads with artifact urls, checksums, and the service name', function (): void {
$source = file_get_contents(app_path('classes/edge_gateway_manager.php'));
it('builds the installer around the PHP agent artifacts and management polling config', function (): void {
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
$serviceSource = file_get_contents(app_path('resources/edge-gateway-agent/truckwash-edge-agent.service'));
expect($source)->not->toBeFalse();
expect($source)->toContain("'artifactUrl' => \$this->buildAgentArtifactUrl('agent.mjs')");
expect($source)->toContain("'artifactSha256' => \$this->buildAgentArtifactSha256('agent.mjs')");
expect($source)->toContain("'packageUrl' => \$this->buildAgentArtifactUrl('package.json')");
expect($source)->toContain("'packageSha256' => \$this->buildAgentArtifactSha256('package.json')");
expect($source)->toContain("'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME");
expect($managerSource)->not->toBeFalse();
expect($managerSource)->toContain("'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS");
expect($managerSource)->toContain('fetch_http "Verify install token" "__VERIFY_URL__"');
expect($managerSource)->toContain('fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php"');
expect($managerSource)->toContain('fetch_http "Download systemd service unit" "__SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-agent.service"');
expect($managerSource)->toContain('log_error "Request: GET ${url}"');
expect($managerSource)->toContain('log_error "Response body preview (first 400 bytes):"');
expect($managerSource)->toContain('apt-get install -y curl ca-certificates php-cli php-curl php-mbstring');
expect($managerSource)->toContain('Existing claimed gateway detected; reinstall will reuse saved gateway credentials.');
expect($managerSource)->toContain('run_step "Writing agent config" merge_agent_config "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH"');
expect($managerSource)->toContain('systemctl enable truckwash-edge-agent.service');
expect($managerSource)->toContain('systemctl restart truckwash-edge-agent.service');
expect($managerSource)->toContain('systemctl is-active --quiet truckwash-edge-agent.service');
expect($managerSource)->toContain('run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30');
expect($managerSource)->toContain('run_step "Waiting for post-reinstall heartbeat" wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30');
expect($managerSource)->toContain('journalctl -u truckwash-edge-agent.service -n 40 --no-pager || true');
expect($managerSource)->not->toContain('agent.mjs');
expect($managerSource)->not->toContain('"brokerUrl"');
expect($serviceSource)->toContain('ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php --config /opt/truckwash-edge-agent/config.json');
expect($serviceSource)->not->toContain('node /opt/truckwash-edge-agent/agent.mjs');
});
it('finalizes active update jobs from heartbeat metadata when the agent reports completion or rollback', function (): void {
$source = file_get_contents(app_path('classes/edge_gateway_manager.php'));
it('exposes update payload, credential rotation, and operation endpoints without shell transport wiring', function (): void {
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
$routeSource = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
$agentSource = file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
expect($source)->toContain("\$state = strtoupper(trim((string)(\$lastUpdate['state'] ?? '')));");
expect($source)->toContain("if (!in_array(\$state, ['COMPLETED', 'FAILED', 'ROLLED_BACK'], true)) {");
expect($source)->toContain("\$updateJob->status->set(\$state);");
expect($source)->toContain("'heartbeat_installed_version' => \$payload['installed_version'] ?? \$gateway->installed_version->value()");
expect($source)->toContain("'heartbeat_target_version' => \$payload['target_version'] ?? \$gateway->target_version->value()");
expect($managerSource)->toContain('public function buildUpdateOperationRequest');
expect($managerSource)->toContain('public function rotateGatewayCredentials');
expect($routeSource)->toContain("'/edge-gateways/{id}/rotate-credentials'");
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/operations/next'");
expect($agentSource)->toContain("/operations/next");
expect($agentSource)->toContain("/operations/' . \$operationId . '/complete");
expect($agentSource)->toContain('last-heartbeat-ok.txt');
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions'");
expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
});
@@ -108,7 +108,7 @@ it('calculates workfeed employee hours across multiple departments for one hour
expect($hours)->toBe(2.0);
});
it('counts overtime minutes when a shift carries an extended approval end timestamp', function (): void {
it('counts overtime minutes when a saved shift end extends past the approved original end', function (): void {
$route = new moduleWeatherAPIRoute();
$slot = new DateTime('2026-03-23T18:00:00+00:00');
@@ -116,9 +116,9 @@ it('counts overtime minutes when a shift carries an extended approval end timest
(object)[
'departmentID' => 'dep_1',
'start' => '2026-03-23T10:00:00+00:00',
'end' => '2026-03-23T18:00:00+00:00',
'end' => '2026-03-23T18:17:00+00:00',
'approval' => (object)[
'originalEnd' => '2026-03-23T18:17:00+00:00',
'originalEnd' => '2026-03-23T18:00:00+00:00',
],
],
];
@@ -128,6 +128,26 @@ it('counts overtime minutes when a shift carries an extended approval end timest
expect($hours)->toBe(0.28);
});
it('does not count removed approved time when a saved shift end is shortened', function (): void {
$route = new moduleWeatherAPIRoute();
$slot = new DateTime('2026-03-23T17:00:00+00:00');
$shifts = [
(object)[
'departmentID' => 'dep_1',
'start' => '2026-03-23T10:00:00+00:00',
'end' => '2026-03-23T17:00:00+00:00',
'approval' => (object)[
'originalEnd' => '2026-03-23T18:00:00+00:00',
],
],
];
$hours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $slot]);
expect($hours)->toBe(0.0);
});
it('counts unapproved overtime from a bounded shift updateTime fallback', function (): void {
$route = new moduleWeatherAPIRoute();
$slot = new DateTime('2026-03-23T17:00:00+00:00');
@@ -166,6 +186,27 @@ it('does not treat late unapproved administrative edits as overtime', function (
expect($hours)->toBe(0.0);
});
it('caps current-slot hours to elapsed minutes and zeroes future slots', function (): void {
$route = new moduleWeatherAPIRoute();
$currentSlot = new DateTime('2026-03-24T13:00:00+00:00');
$futureSlot = new DateTime('2026-03-24T14:00:00+00:00');
$occurredUntil = new DateTime('2026-03-24T13:15:00+00:00');
$shifts = [
(object)[
'departmentID' => 'dep_1',
'start' => '2026-03-24T13:00:00+00:00',
'end' => '2026-03-24T15:00:00+00:00',
],
];
$currentHours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $currentSlot, $occurredUntil]);
$futureHours = weather_route_invoke_private($route, 'calculateWorkfeedEmployeeHoursForHour', [$shifts, 'dep_1', $futureSlot, $occurredUntil]);
expect($currentHours)->toBe(0.25);
expect($futureHours)->toBe(0.0);
});
it('normalizes department id input from scalar csv and nested array values', function (): void {
$route = new moduleWeatherAPIRoute();
@@ -37,7 +37,7 @@ trait module_config_t
{
// Check if the request has a variable name and value
global $response;
if ($response->getRequestParameter('variable') === null || $response->getRequestParameter('value') === null) {
if ($response->getRequestParameter('variable') === null || !$response->isRequestParameterSet('value')) {
$response->error('Variable and value not set', 400);
}
// Get the variable name and value
@@ -144,6 +144,9 @@ trait module_config_t
{
// If the variable is an int, convert it to an int
if ($type == 'integer' || $type == 'int') {
if ($value === null || $value === '') {
return null;
}
return (integer)$value;
}
// If the variable is a boolean, convert it to a boolean
@@ -171,6 +171,14 @@ trait module_config_variable
*/
function validateVariableValue(mixed $value): bool
{
if ($value === null) {
return !$this->config_variable_required;
}
if ($value === '' && !$this->config_variable_required && $this->config_variable_type === 'int') {
return true;
}
// Check if the value is empty and the variable is required
if ($this->config_variable_required && empty($value)) {
// If the value is empty and the type is not a boolean, return false
+1
View File
@@ -4,3 +4,4 @@
[24-Mar-2026 10:12:39 UTC] PHP Warning: PHP Startup: Unable to load dynamic library 'elastic_apm.so' (tried: /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so: cannot open shared object file: No such file or directory), /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so: cannot open shared object file: No such file or directory)) in Unknown on line 0
[01-Apr-2026 10:49:51 UTC] PHP Warning: PHP Startup: Unable to load dynamic library 'elastic_apm.so' (tried: /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so: cannot open shared object file: No such file or directory), /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so: cannot open shared object file: No such file or directory)) in Unknown on line 0
[07-Apr-2026 14:28:09 UTC] PHP Warning: PHP Startup: Unable to load dynamic library 'elastic_apm.so' (tried: /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so: cannot open shared object file: No such file or directory), /usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so (/usr/local/lib/php/extensions/no-debug-non-zts-20220829/elastic_apm.so.so: cannot open shared object file: No such file or directory)) in Unknown on line 0
[20-Apr-2026 12:45:29 UTC] PHP Parse error: syntax error, unexpected end of file in Command line code on line 1