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:
@@ -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."
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 (Project‑specific)
|
||||
<!-- 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 framework‑based tests.
|
||||
- Keep unit‑style 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
|
||||
- Unit‑style (preferred): run on host with PHP CLI.
|
||||
- Command: `php services/nginx/app/tests/<domain>/<Name>Test.php`
|
||||
- Integration‑style (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 unit‑style tests: include only the files you exercise via explicit `require_once WD . '/path/to/file.php';`.
|
||||
- Do NOT include `config.php` for unit‑style tests; it throws if `USE_ENV` is not set.
|
||||
- For integration‑style 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 human‑readable 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 non‑zero 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, e‑conomic, 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 (integration‑only)
|
||||
- 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 unit‑style test script should complete in < 100 ms on a typical dev machine.
|
||||
- Each integration‑style 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 unit‑style 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 behavior‑preserving 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`: UTF‑8, CRLF, 4‑space indent, class brace `next_line`).
|
||||
|
||||
12. Templates
|
||||
- Unit‑style 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);
|
||||
```
|
||||
|
||||
- Integration‑style 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 {
|
||||
// Best‑effort cleanup
|
||||
try { $r->del($key); } catch (\Throwable $e) { /* ignore */ }
|
||||
}
|
||||
|
||||
echo "\nExampleRedisIntegrationTest completed.\n";
|
||||
exit($failed);
|
||||
```
|
||||
|
||||
13. Quick reference
|
||||
- Run unit‑style test on host: `php services/nginx/app/tests/<domain>/<Name>Test.php`
|
||||
- Run integration‑style 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 (Project‑specific)
|
||||
|
||||
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 framework‑based tests.
|
||||
- Keep unit‑style 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
|
||||
- Unit‑style (preferred): run on host with PHP CLI.
|
||||
- Command: `php services/nginx/app/tests/<domain>/<Name>Test.php`
|
||||
- Integration‑style (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 unit‑style tests: include only the files you exercise via explicit `require_once WD . '/path/to/file.php';`.
|
||||
- Do NOT include `config.php` for unit‑style tests; it throws if `USE_ENV` is not set.
|
||||
- For integration‑style 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 human‑readable 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 non‑zero 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, e‑conomic, 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 (integration‑only)
|
||||
- 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 unit‑style test script should complete in < 100 ms on a typical dev machine.
|
||||
- Each integration‑style 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 unit‑style 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 behavior‑preserving 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`: UTF‑8, CRLF, 4‑space indent, class brace `next_line`).
|
||||
|
||||
12. Templates
|
||||
- Unit‑style 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);
|
||||
```
|
||||
|
||||
- Integration‑style 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 {
|
||||
// Best‑effort cleanup
|
||||
try { $r->del($key); } catch (\Throwable $e) { /* ignore */ }
|
||||
}
|
||||
|
||||
echo "\nExampleRedisIntegrationTest completed.\n";
|
||||
exit($failed);
|
||||
```
|
||||
|
||||
13. Quick reference
|
||||
- Run unit‑style test on host: `php services/nginx/app/tests/<domain>/<Name>Test.php`
|
||||
- Run integration‑style 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 (Project‑specific)
|
||||
<!-- 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 (read‑only 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 strip‑prefix `/api` → still routes to `/foo/bar` in the app)
|
||||
- Keep paths meaningful and resource‑oriented. 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, non‑sensitive 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 human‑readable 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 rate‑limit or abuse‑mitigation 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 route’s docblock and in `openapi.yaml`.
|
||||
- Wrap multi‑step operations with proper validation and permission checks before any external calls (Stripe, e‑conomic, etc.). Do not leak third‑party 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 Traefik’s `/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 self‑serve 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 unit‑style tests for core logic; see “Creating and maintaining tests — Rules”.
|
||||
|
||||
15. Code style and hygiene
|
||||
- Follow `.editorconfig` (UTF‑8, CRLF, 4‑space indents).
|
||||
- Keep route files cohesive; avoid adding unrelated endpoints to the same class. If a class exceeds ~200–300 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`.
|
||||
|
||||
@@ -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
|
||||
'''
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -10,3 +10,4 @@
|
||||
/.idea/
|
||||
.env
|
||||
/services/caddy/logs*
|
||||
/.tmp/
|
||||
|
||||
+19
-139
@@ -1,148 +1,28 @@
|
||||
### Copenhagen Truck Wash API — Development Guidelines (Project‑specific)
|
||||
<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->
|
||||
|
||||
#### Scope
|
||||
This document captures project‑specific 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 PHP‑FPM containers (`php1`..`php5`), sharing the bind‑mounted 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 env‑agnostic.
|
||||
## Workflow Notes
|
||||
|
||||
- Local run targets and routing:
|
||||
- Traefik exposes:
|
||||
- `http://localhost` → routes to Caddy → app (HTTP only for dev).
|
||||
- `https://localhost` → also mapped, using Traefik’s default/self‑signed dev cert.
|
||||
- `http(s)://localhost/api/*` → Traefik strip‑prefix middleware forwards to Caddy; the app sees paths without the `/api` prefix.
|
||||
- For production‑like 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 bring‑up 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 re‑publish 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
|
||||
- Self‑contained 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.
|
||||
- Integration‑style 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 unit‑style scripts should avoid `config.php` and any I/O; they manually include only what’s 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 self‑contained 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 self‑contained 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, env‑agnostic 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 strip‑prefix `/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 trait‑based. 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 DB‑backed 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: UTF‑8 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 cross‑cutting 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 zero‑downtime 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, e‑conomic, 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, self‑contained test (host PHP):
|
||||
- `php services/nginx/app/tests/subusers/SelfservePermissionInitTest.php`
|
||||
|
||||
- Run a test inside Docker (env‑backed):
|
||||
- `docker compose exec -T php1 php /var/www/html/tests/subusers/SelfservePermissionInitTest.php`
|
||||
Canonical workflow reference: `.ai-workflow/workflow.md`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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"]
|
||||
Vendored
+9
-34
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+28
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+37
-17
@@ -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)),
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,7 +402,16 @@ class orders_o extends db
|
||||
$invoiceCollectionId = $invoiceCollectionId ?? $customer->getNewOrderInvoiceCollectionId();
|
||||
// Assign the order to the invoice collection
|
||||
$this->invoice_collection_id->set($invoiceCollectionId);
|
||||
self::objectChanged();
|
||||
if ($previousInvoiceCollectionId > 0 && $previousInvoiceCollectionId !== (int)$invoiceCollectionId) {
|
||||
$previousCollection = new collected_order_invoices_o();
|
||||
$previousCollection->select($previousInvoiceCollectionId);
|
||||
if ($previousCollection->exists()) {
|
||||
$previousCollection->objectChanged();
|
||||
}
|
||||
}
|
||||
if ($notifyChanges) {
|
||||
self::objectChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () {
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
|
||||
$departmentId = self::isParametersSet(['department_id']) ? (int)self::getParameter('department_id') : null;
|
||||
if ($departmentId !== null && $departmentId > 0) {
|
||||
$this->requireDepartmentAccess((int)$departmentId);
|
||||
}
|
||||
|
||||
$response->success((new edge_gateway_manager())->listGateways($departmentId));
|
||||
}, [
|
||||
$this->get('/edge-gateways', fn() => $this->handleListGateways(), [
|
||||
'modules_shelly_config' => 'Manage department edge gateways for local Shelly control',
|
||||
]);
|
||||
|
||||
$this->get('/edge-gateways/{id}', function () {
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
|
||||
$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);
|
||||
}, [
|
||||
$this->get('/edge-gateways/{id}', fn() => $this->handleGatewayDetail(), [
|
||||
'modules_shelly_config' => 'View department edge gateway detail',
|
||||
]);
|
||||
|
||||
$this->post('/edge-gateways/install-token', function () {
|
||||
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(
|
||||
$departmentId,
|
||||
self::isParametersSet(['label']) ? (string)self::getParameter('label') : null,
|
||||
$user ? (int)$user->id : null
|
||||
),
|
||||
201
|
||||
);
|
||||
}, [
|
||||
$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', 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->queueDiscovery($gatewayId, $user ? (int)$user->id : null));
|
||||
}, [
|
||||
'modules_shelly_config' => 'Trigger a Shelly LAN discovery job on an edge gateway',
|
||||
$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', function () {
|
||||
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']);
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
$response->success($manager->setRelayBindings(
|
||||
$gatewayId,
|
||||
(array)self::getParameter('bindings'),
|
||||
$user ? (int)$user->id : null
|
||||
));
|
||||
}, [
|
||||
$this->put('/edge-gateways/{id}/bindings', fn() => $this->handleBindingsUpdate(), [
|
||||
'modules_shelly_config' => 'Approve or override relay bindings for an edge gateway',
|
||||
]);
|
||||
|
||||
$this->post('/edge-gateways/{id}/update-jobs', function () {
|
||||
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']);
|
||||
|
||||
$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 () {
|
||||
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));
|
||||
}, [
|
||||
$this->delete('/edge-gateways/{id}', fn() => $this->handleGatewayDelete(), [
|
||||
'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');
|
||||
|
||||
$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
|
||||
));
|
||||
}, [
|
||||
$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.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->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}/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());
|
||||
$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($departmentId);
|
||||
}
|
||||
|
||||
$gateways = $this->views()->listGateways($departmentId, $view !== 'summary');
|
||||
$response->add_meta('fleet_usage', $this->views()->buildFleetUsageStatistics($departmentId, $gateways));
|
||||
$response->success($gateways);
|
||||
}
|
||||
|
||||
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');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
|
||||
$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);
|
||||
|
||||
$response->success(
|
||||
$this->registry()->createInstallToken(
|
||||
$departmentId,
|
||||
self::isParametersSet(['label']) ? (string)self::getParameter('label') : null,
|
||||
$this->actorUserId()
|
||||
),
|
||||
201
|
||||
);
|
||||
}
|
||||
|
||||
private function handleGatewayDiscovery(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
|
||||
$this->operations()->queueDiscoveryOperation($gatewayId, $this->actorUserId());
|
||||
$response->success($this->views()->getGateway($gatewayId));
|
||||
}
|
||||
|
||||
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');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$this->registry()->setRelayBindings($gatewayId, (array)self::getParameter('bindings'), $this->actorUserId());
|
||||
$response->success($this->views()->getGateway($gatewayId));
|
||||
}
|
||||
|
||||
private function handleGatewayDelete(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$response->success($this->registry()->deleteGateway($gatewayId, $this->actorUserId()));
|
||||
}
|
||||
|
||||
private function handleDepartmentCutover(): void
|
||||
{
|
||||
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);
|
||||
$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(
|
||||
(string)$payload['token'],
|
||||
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
|
||||
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
|
||||
(array)($payload['metadata'] ?? [])
|
||||
), 201);
|
||||
|
||||
$response->success(
|
||||
$this->registry()->claimGateway(
|
||||
(string)$payload['token'],
|
||||
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
|
||||
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
|
||||
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
|
||||
),
|
||||
201
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
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'
|
||||
));
|
||||
self::requireParameterIntPositive($gatewayId, 'id');
|
||||
$gateway = $this->views()->getGateway($gatewayId);
|
||||
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
||||
return $gateway;
|
||||
}
|
||||
|
||||
private function requireBrokerSharedSecret(): void
|
||||
private function requireAgentToken(array $payload): string
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$expected = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
|
||||
if ($expected === '') {
|
||||
$response->error('Edge broker secret is not configured', 503);
|
||||
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
|
||||
if ($token === '') {
|
||||
$response->error('Missing edge gateway agent token', 401);
|
||||
}
|
||||
|
||||
$provided = trim((string)($_SERVER['HTTP_X_EDGE_BROKER_SECRET'] ?? $this->fromRequest('broker_secret')));
|
||||
if ($provided === '' || !hash_equals($expected, $provided)) {
|
||||
$response->error('Forbidden', 403);
|
||||
}
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function actorUserId(): ?int
|
||||
{
|
||||
$user = (new authentication())->get_user();
|
||||
return $user ? (int)$user->id : null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
$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(); },
|
||||
$object->forceRestrictFilters([
|
||||
...($has_permission_other && $user !== false ? [
|
||||
'department' => $user->getGroup()->getDepartments()
|
||||
] : []),
|
||||
...(!$has_permission_other && $effectiveCustomer !== null ? [
|
||||
'customer_number' => [(int)$effectiveCustomer]
|
||||
] : [])
|
||||
])
|
||||
));
|
||||
$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
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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();');
|
||||
});
|
||||
+35
-4
@@ -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();
|
||||
});
|
||||
+17
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user